/// <reference types="@types/jest" />;
import { jest, jest as mockJest, it, describe, beforeEach, afterEach, expect } from '@jest/globals';
import global from 'global';
import merge from 'lodash/merge';
import {
  CONFIG_ERROR,
  CURRENT_STORY_WAS_SET,
  DOCS_RENDERED,
  FORCE_REMOUNT,
  FORCE_RE_RENDER,
  GLOBALS_UPDATED,
  IGNORED_EXCEPTION,
  PREVIEW_KEYDOWN,
  RESET_STORY_ARGS,
  SET_CURRENT_STORY,
  SET_GLOBALS,
  STORY_ARGS_UPDATED,
  STORY_CHANGED,
  STORY_ERRORED,
  STORY_MISSING,
  STORY_PREPARED,
  STORY_RENDERED,
  STORY_SPECIFIED,
  STORY_THREW_EXCEPTION,
  PLAY_FUNCTION_THREW_EXCEPTION,
  STORY_UNCHANGED,
  UPDATE_GLOBALS,
  UPDATE_STORY_ARGS,
} from '@storybook/core-events';
import { logger } from '@storybook/client-logger';
import { addons, mockChannel as createMockChannel } from '@storybook/addons';
import type { AnyFramework } from '@storybook/csf';
import type { ModuleImportFn, WebProjectAnnotations } from '@storybook/store';
import { mocked } from 'ts-jest/utils';

import { PreviewWeb } from './PreviewWeb';
import {
  componentOneExports,
  componentTwoExports,
  importFn,
  projectAnnotations,
  getProjectAnnotations,
  storyIndex,
  emitter,
  mockChannel,
  waitForEvents,
  waitForRender,
  waitForQuiescence,
  waitForRenderPhase,
  docsRenderer,
  standaloneDocsExports,
  teardownRenderToDOM,
} from './PreviewWeb.mockdata';
import { WebView } from './WebView';

const { history, document } = global;

const mockStoryIndex = jest.fn(() => storyIndex);

let mockFetchResult;
jest.mock('global', () => ({
  ...(mockJest.requireActual('global') as any),
  history: { replaceState: mockJest.fn() },
  document: {
    location: {
      pathname: 'pathname',
      search: '?id=*',
    },
  },
  window: {
    location: {
      reload: mockJest.fn(),
    },
  },
  FEATURES: {
    storyStoreV7: true,
    breakingChangesV7: true,
    // xxx
  },
  fetch: async () => mockFetchResult,
}));

jest.mock('@storybook/client-logger');
jest.mock('react-dom');
jest.mock('./WebView');

const serializeError = (error: Error) => {
  const { name = 'Error', message = String(error), stack } = error;
  return { name, message, stack };
};

const createGate = (): [Promise<any | undefined>, (_?: any) => void] => {
  let openGate = (_?: any) => {};
  const gate = new Promise<any | undefined>((resolve) => {
    openGate = resolve;
  });
  return [gate, openGate];
};

// SET_CURRENT_STORY does some stuff in promises, then waits for
// a timer, so we need to first setImmediate (to get past the resolution), then run the timers
// Probably jest modern timers do this but they aren't working for some bizzarre reason.
async function waitForSetCurrentStory() {
  await new Promise((r) => setImmediate(r));
  jest.runAllTimers();
}

async function createAndRenderPreview({
  importFn: inputImportFn = importFn,
  getProjectAnnotations: inputGetProjectAnnotations = getProjectAnnotations,
}: {
  importFn?: ModuleImportFn;
  getProjectAnnotations?: () => WebProjectAnnotations<AnyFramework>;
} = {}) {
  const preview = new PreviewWeb();
  await preview.initialize({
    importFn: inputImportFn,
    getProjectAnnotations: inputGetProjectAnnotations,
  });
  await waitForRender();

  return preview;
}

beforeEach(() => {
  document.location.search = '';
  mockChannel.emit.mockClear();
  emitter.removeAllListeners();
  componentOneExports.default.loaders[0].mockReset().mockImplementation(async () => ({ l: 7 }));
  componentOneExports.a.play.mockReset();
  teardownRenderToDOM.mockReset();
  projectAnnotations.renderToDOM.mockReset().mockReturnValue(teardownRenderToDOM);
  projectAnnotations.render.mockClear();
  projectAnnotations.decorators[0].mockClear();
  docsRenderer.render.mockClear();
  (logger.warn as jest.Mock<typeof logger.warn>).mockClear();
  mockStoryIndex.mockReset().mockReturnValue(storyIndex);

  addons.setChannel(mockChannel as any);
  addons.setServerChannel(createMockChannel());
  mockFetchResult = { status: 200, json: mockStoryIndex, text: () => 'error text' };

  mocked(WebView.prototype).prepareForDocs.mockReturnValue('docs-element' as any);
  mocked(WebView.prototype).prepareForStory.mockReturnValue('story-element' as any);
});

describe('PreviewWeb', () => {
  describe('initialize', () => {
    it('shows an error if getProjectAnnotations throws', async () => {
      const err = new Error('meta error');
      const preview = new PreviewWeb();
      await expect(
        preview.initialize({
          importFn,
          getProjectAnnotations: () => {
            throw err;
          },
        })
      ).rejects.toThrow(err);

      expect(preview.view.showErrorDisplay).toHaveBeenCalled();
      expect(mockChannel.emit).toHaveBeenCalledWith(CONFIG_ERROR, err);
    });

    it('shows an error if the stories.json endpoint 500s', async () => {
      const err = new Error('sort error');
      mockFetchResult = { status: 500, text: async () => err.toString() };

      const preview = new PreviewWeb();
      await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(
        'sort error'
      );

      expect(preview.view.showErrorDisplay).toHaveBeenCalled();
      expect(mockChannel.emit).toHaveBeenCalledWith(CONFIG_ERROR, expect.any(Error));
    });

    it('sets globals from the URL', async () => {
      document.location.search = '?id=*&globals=a:c';

      const preview = await createAndRenderPreview();

      expect(preview.storyStore.globals!.get()).toEqual({ a: 'c' });
    });

    it('emits the SET_GLOBALS event', async () => {
      await createAndRenderPreview();

      expect(mockChannel.emit).toHaveBeenCalledWith(SET_GLOBALS, {
        globals: { a: 'b' },
        globalTypes: {},
      });
    });

    it('SET_GLOBALS sets globals and types even when undefined', async () => {
      await createAndRenderPreview({ getProjectAnnotations: () => ({ renderToDOM: jest.fn() }) });

      expect(mockChannel.emit).toHaveBeenCalledWith(SET_GLOBALS, {
        globals: {},
        globalTypes: {},
      });
    });

    it('emits the SET_GLOBALS event from the URL', async () => {
      document.location.search = '?id=*&globals=a:c';

      await createAndRenderPreview();

      expect(mockChannel.emit).toHaveBeenCalledWith(SET_GLOBALS, {
        globals: { a: 'c' },
        globalTypes: {},
      });
    });

    it('sets args from the URL', async () => {
      document.location.search = '?id=component-one--a&args=foo:url';

      const preview = await createAndRenderPreview();

      expect(preview.storyStore.args.get('component-one--a')).toEqual({
        foo: 'url',
      });
    });
    it('updates args from the URL', async () => {
      document.location.search = '?id=component-one--a&args=foo:url';

      await createAndRenderPreview();

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'url' },
      });
    });

    it('allows async getProjectAnnotations', async () => {
      const preview = new PreviewWeb();
      await preview.initialize({
        importFn,
        getProjectAnnotations: async () => {
          return getProjectAnnotations();
        },
      });

      expect(preview.storyStore.globals!.get()).toEqual({ a: 'b' });
    });
  });

  describe('initial selection', () => {
    it('selects the story specified in the URL', async () => {
      document.location.search = '?id=component-one--a';

      const preview = await createAndRenderPreview();

      expect(preview.urlStore.selection).toEqual({
        storyId: 'component-one--a',
        viewMode: 'story',
      });
      expect(history.replaceState).toHaveBeenCalledWith(
        {},
        '',
        'pathname?id=component-one--a&viewMode=story'
      );
    });

    it('emits the STORY_SPECIFIED event', async () => {
      document.location.search = '?id=component-one--a';

      await createAndRenderPreview();

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_SPECIFIED, {
        storyId: 'component-one--a',
        viewMode: 'story',
      });
    });

    it('emits the CURRENT_STORY_WAS_SET event', async () => {
      document.location.search = '?id=component-one--a';

      await createAndRenderPreview();

      expect(mockChannel.emit).toHaveBeenCalledWith(CURRENT_STORY_WAS_SET, {
        storyId: 'component-one--a',
        viewMode: 'story',
      });
    });

    describe('when the first entry is a docs entry', () => {
      it('emits the STORY_SPECIFIED event with viewMode=docs', async () => {
        document.location.search = '?id=*';
        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_SPECIFIED, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
      });
    });

    describe('if the story specified does not exist', () => {
      it('renders a loading error', async () => {
        document.location.search = '?id=random';

        const preview = await createAndRenderPreview();

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING, 'random');
      });

      it('tries again with a specifier if CSF file changes', async () => {
        document.location.search = '?id=component-one--missing';

        const preview = await createAndRenderPreview();

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING, 'component-one--missing');

        mockChannel.emit.mockClear();
        const newComponentOneExports = merge({}, componentOneExports, {
          d: { args: { foo: 'd' }, play: jest.fn() },
        });
        const newImportFn = jest.fn(async (path) => {
          return path === './src/ComponentOne.stories.js'
            ? newComponentOneExports
            : componentTwoExports;
        });
        preview.onStoriesChanged({
          importFn: newImportFn,
          storyIndex: {
            v: 4,
            entries: {
              ...storyIndex.entries,
              'component-one--missing': {
                type: 'story',
                id: 'component-one--missing',
                title: 'Component One',
                name: 'D',
                importPath: './src/ComponentOne.stories.js',
              },
            },
          },
        });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_SPECIFIED, {
          storyId: 'component-one--missing',
          viewMode: 'story',
        });
      });

      describe('after selection changes', () => {
        beforeEach(() => {
          jest.useFakeTimers();
        });
        afterEach(() => {
          jest.useRealTimers();
        });

        it('DOES NOT try again if CSF file changes', async () => {
          document.location.search = '?id=component-one--missing';

          const preview = await createAndRenderPreview();

          expect(preview.view.showErrorDisplay).toHaveBeenCalled();
          expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING, 'component-one--missing');

          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();

          const newComponentOneExports = merge({}, componentOneExports, {
            d: { args: { foo: 'd' }, play: jest.fn() },
          });
          const newImportFn = jest.fn(async (path) => {
            return path === './src/ComponentOne.stories.js'
              ? newComponentOneExports
              : componentTwoExports;
          });

          preview.onStoriesChanged({
            importFn: newImportFn,
            storyIndex: {
              v: 4,
              entries: {
                ...storyIndex.entries,
                'component-one--missing': {
                  type: 'story',
                  id: 'component-one--missing',
                  title: 'Component One',
                  name: 'D',
                  importPath: './src/ComponentOne.stories.js',
                },
              },
            },
          });
          expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_SPECIFIED, {
            storyId: 'component-one--missing',
            viewMode: 'story',
          });
        });
      });
    });

    it('renders missing if no selection', async () => {
      const preview = await createAndRenderPreview();

      expect(preview.view.showNoPreview).toHaveBeenCalled();
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING);
    });

    describe('story entries', () => {
      it('calls view.prepareForStory', async () => {
        document.location.search = '?id=component-one--a';

        const preview = await createAndRenderPreview();

        expect(preview.view.prepareForStory).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--a',
          })
        );
      });

      it('emits STORY_PREPARED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_PREPARED, {
          id: 'component-one--a',
          parameters: {
            __isArgsStory: false,
            docs: expect.any(Object),
            fileName: './src/ComponentOne.stories.js',
          },
          initialArgs: { foo: 'a' },
          argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
          args: { foo: 'a' },
        });
      });

      it('applies loaders with story context', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--a',
            parameters: {
              __isArgsStory: false,
              docs: expect.any(Object),
              fileName: './src/ComponentOne.stories.js',
            },
            initialArgs: { foo: 'a' },
            argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
            args: { foo: 'a' },
          })
        );
      });

      it('passes loaded context to renderToDOM', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--a',
              parameters: {
                __isArgsStory: false,
                docs: expect.any(Object),
                fileName: './src/ComponentOne.stories.js',
              },
              globals: { a: 'b' },
              initialArgs: { foo: 'a' },
              argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
              args: { foo: 'a' },
              loaded: { l: 7 },
            }),
          }),
          'story-element'
        );
      });

      it('renders exception if a loader throws', async () => {
        const error = new Error('error');
        componentOneExports.default.loaders[0].mockImplementationOnce(() => {
          throw error;
        });

        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders exception if renderToDOM throws', async () => {
        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation(() => {
          throw error;
        });

        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders helpful message if renderToDOM is undefined', async () => {
        document.location.search = '?id=component-one--a';

        getProjectAnnotations.mockReturnValueOnce({
          ...projectAnnotations,
          renderToDOM: undefined,
        });
        const preview = new PreviewWeb();
        await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow();

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect((preview.view.showErrorDisplay as jest.Mock).mock.calls[0][0])
          .toMatchInlineSnapshot(`
                          [Error: Expected your framework's preset to export a \`renderToDOM\` field.

                          Perhaps it needs to be upgraded for Storybook 6.4?

                          More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field]
                      `);
      });

      describe('when `throwPlayFunctionExceptions` is set', () => {
        it('emits but does not render exception if the play function throws', async () => {
          const error = new Error('error');
          componentOneExports.a.play.mockImplementationOnce(() => {
            throw error;
          });

          getProjectAnnotations.mockReturnValueOnce({
            ...projectAnnotations,
            parameters: {
              ...projectAnnotations.parameters,
              throwPlayFunctionExceptions: false,
            },
          });

          document.location.search = '?id=component-one--a';
          const preview = await createAndRenderPreview();

          expect(mockChannel.emit).toHaveBeenCalledWith(
            PLAY_FUNCTION_THREW_EXCEPTION,
            serializeError(error)
          );
          expect(preview.view.showErrorDisplay).not.toHaveBeenCalled();
          expect(mockChannel.emit).not.toHaveBeenCalledWith(
            STORY_THREW_EXCEPTION,
            serializeError(error)
          );
        });
      });

      describe('when `throwPlayFunctionExceptions` is unset', () => {
        it('emits AND renders exception if the play function throws', async () => {
          const error = new Error('error');
          componentOneExports.a.play.mockImplementationOnce(() => {
            throw error;
          });

          document.location.search = '?id=component-one--a';
          const preview = await createAndRenderPreview();

          expect(mockChannel.emit).toHaveBeenCalledWith(
            PLAY_FUNCTION_THREW_EXCEPTION,
            serializeError(error)
          );
          expect(preview.view.showErrorDisplay).toHaveBeenCalled();
          expect(mockChannel.emit).toHaveBeenCalledWith(
            STORY_THREW_EXCEPTION,
            serializeError(error)
          );
        });
      });

      it('renders exception if the story calls showException', async () => {
        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation((context) =>
          context.showException(error)
        );

        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders error if the story calls showError', async () => {
        const error = { title: 'title', description: 'description' };
        projectAnnotations.renderToDOM.mockImplementation((context) => context.showError(error));

        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ERRORED, error);
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({
          message: error.title,
          stack: error.description,
        });
      });

      it('executes playFunction', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        expect(componentOneExports.a.play).toHaveBeenCalled();
      });

      it('emits STORY_RENDERED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });

      it('does not show error display if the render function throws IGNORED_EXCEPTION', async () => {
        document.location.search = '?id=component-one--a';
        projectAnnotations.renderToDOM.mockImplementation(() => {
          throw IGNORED_EXCEPTION;
        });

        const preview = new PreviewWeb();
        await preview.initialize({ importFn, getProjectAnnotations });

        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(
          STORY_THREW_EXCEPTION,
          serializeError(IGNORED_EXCEPTION)
        );
        expect(preview.view.showErrorDisplay).not.toHaveBeenCalled();
      });
    });

    describe('template docs entries', () => {
      it('always renders in docs viewMode', async () => {
        document.location.search = '?id=component-one--docs';
        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'component-one--docs');
      });

      it('calls view.prepareForDocs', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        expect(preview.view.prepareForDocs).toHaveBeenCalled();
      });

      it('renders with docs parameters from the first story', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        expect(docsRenderer.render).toHaveBeenCalledWith(
          expect.any(Object),
          expect.objectContaining({
            page: componentOneExports.default.parameters.docs.page,
            renderer: projectAnnotations.parameters.docs.renderer,
          }),
          'docs-element',
          expect.any(Function)
        );
      });

      it('loads imports of the docs entry', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        expect(importFn).toHaveBeenCalledWith('./src/ExtraComponentOne.stories.js');
      });

      it('renders with componentStories loaded from both story files', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        const context = docsRenderer.render.mock.calls[0][0];

        expect(context.componentStories().map((s) => s.id)).toEqual([
          'component-one--a',
          'component-one--b',
          'component-one--e',
        ]);
      });

      it('emits DOCS_RENDERED', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';

        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'component-one--docs');
      });
    });

    describe('standalone docs entries', () => {
      it('always renders in docs viewMode', async () => {
        document.location.search = '?id=introduction--docs';
        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'introduction--docs');
      });

      it('calls view.prepareForDocs', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        expect(preview.view.prepareForDocs).toHaveBeenCalled();
      });

      it('renders with the generated docs parameters', async () => {
        document.location.search = '?id=introduction--docs&viewMode=docs';
        await createAndRenderPreview();

        expect(docsRenderer.render).toHaveBeenCalledWith(
          expect.any(Object),
          expect.objectContaining({
            page: standaloneDocsExports.default,
            renderer: projectAnnotations.parameters.docs.renderer,
          }),
          'docs-element',
          expect.any(Function)
        );
      });

      it('loads imports of the docs entry', async () => {
        document.location.search = '?id=introduction--docs';
        await createAndRenderPreview();

        expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js');
      });

      it('emits DOCS_RENDERED', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';

        await createAndRenderPreview();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'component-one--docs');
      });
    });
  });

  describe('onUpdateGlobals', () => {
    it('emits GLOBALS_UPDATED', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });

      await waitForEvents([GLOBALS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(GLOBALS_UPDATED, {
        globals: { a: 'b', foo: 'bar' },
        initialGlobals: { a: 'b' },
      });
    });

    it('sets new globals on the store', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });

      expect(preview.storyStore.globals!.get()).toEqual({ a: 'b', foo: 'bar' });
    });

    it('passes new globals in context to renderToDOM', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      projectAnnotations.renderToDOM.mockClear();
      emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });
      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            globals: { a: 'b', foo: 'bar' },
          }),
        }),
        'story-element'
      );
    });

    it('emits STORY_RENDERED', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });
      await waitForRender();

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
    });

    describe('in docs mode', () => {
      it('re-renders the docs container', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';

        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        docsRenderer.render.mockClear();
        emitter.emit(UPDATE_GLOBALS, { globals: { foo: 'bar' } });
        await waitForEvents([GLOBALS_UPDATED]);

        expect(docsRenderer.render).toHaveBeenCalled();
      });
    });
  });

  describe('onUpdateArgs', () => {
    it('emits STORY_ARGS_UPDATED', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { new: 'arg' },
      });

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a', new: 'arg' },
      });
    });

    it('sets new args on the store', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { new: 'arg' },
      });

      expect(preview.storyStore.args.get('component-one--a')).toEqual({
        foo: 'a',
        new: 'arg',
      });
    });

    it('passes new args in context to renderToDOM', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      projectAnnotations.renderToDOM.mockClear();
      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { new: 'arg' },
      });
      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            initialArgs: { foo: 'a' },
            args: { foo: 'a', new: 'arg' },
          }),
        }),
        'story-element'
      );
    });

    it('emits STORY_RENDERED', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { new: 'arg' },
      });
      await waitForRender();

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
    });

    describe('while story is still rendering', () => {
      it('runs loaders again', async () => {
        const [gate, openGate] = createGate();

        document.location.search = '?id=component-one--a';
        componentOneExports.default.loaders[0].mockImplementationOnce(async () => gate);

        await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
        await waitForRenderPhase('loading');

        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            args: { foo: 'a' },
          })
        );

        componentOneExports.default.loaders[0].mockClear();
        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { new: 'arg' },
        });
        await waitForRender();

        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            args: { foo: 'a', new: 'arg' },
          })
        );

        // Story gets rendered with updated args
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(1);
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true, // Wasn't yet rendered so we need to force remount
            storyContext: expect.objectContaining({
              loaded: { l: 7 }, // This is the value returned by the *second* loader call
              args: { foo: 'a', new: 'arg' },
            }),
          }),
          'story-element'
        );

        // Now let the first loader call resolve
        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        openGate({ l: 8 });
        await waitForRender();

        // Now the first call comes through, but picks up the new args
        // Note this isn't a particularly realistic case (the second loader being quicker than the first)
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(1);
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            storyContext: expect.objectContaining({
              loaded: { l: 8 },
              args: { foo: 'a', new: 'arg' },
            }),
          }),
          'story-element'
        );
      });

      it('renders a second time if renderToDOM is running', async () => {
        const [gate, openGate] = createGate();

        document.location.search = '?id=component-one--a';
        projectAnnotations.renderToDOM.mockImplementation(async () => gate);

        await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
        await waitForRenderPhase('rendering');

        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { new: 'arg' },
        });

        // Now let the renderToDOM call resolve
        openGate();
        await waitForRender();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(2);
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a' },
            }),
          }),
          'story-element'
        );
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: false,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a', new: 'arg' },
            }),
          }),
          'story-element'
        );
      });

      it('works if it is called directly from inside non async renderToDOM', async () => {
        document.location.search = '?id=component-one--a';
        projectAnnotations.renderToDOM.mockImplementation(() => {
          emitter.emit(UPDATE_STORY_ARGS, {
            storyId: 'component-one--a',
            updatedArgs: { new: 'arg' },
          });
        });
        await createAndRenderPreview();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(2);
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a' },
            }),
          }),
          'story-element'
        );
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: false,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a', new: 'arg' },
            }),
          }),
          'story-element'
        );
      });

      it('calls renderToDOM again if play function is running', async () => {
        const [gate, openGate] = createGate();
        componentOneExports.a.play.mockImplementationOnce(async () => gate);

        const renderToDOMCalled = new Promise((resolve) => {
          projectAnnotations.renderToDOM.mockImplementation(() => {
            resolve(null);
          });
        });

        document.location.search = '?id=component-one--a';
        await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
        await waitForRenderPhase('playing');

        await renderToDOMCalled;
        // Story gets rendered with original args
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a' },
            }),
          }),
          'story-element'
        );

        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { new: 'arg' },
        });

        // The second call should emit STORY_RENDERED
        await waitForRender();

        // Story gets rendered with updated args
        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: false,
            storyContext: expect.objectContaining({
              loaded: { l: 7 },
              args: { foo: 'a', new: 'arg' },
            }),
          }),
          'story-element'
        );

        // Now let the playFunction call resolve
        openGate();
      });
    });

    describe('in docs mode', () => {
      it('does not re-render the docs container', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';

        await createAndRenderPreview();

        docsRenderer.render.mockClear();
        mockChannel.emit.mockClear();
        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { new: 'arg' },
        });
        await waitForEvents([STORY_ARGS_UPDATED]);

        expect(docsRenderer.render).not.toHaveBeenCalled();
      });

      describe('when renderStoryToElement was called', () => {
        it('re-renders the story', async () => {
          document.location.search = '?id=component-one--docs&viewMode=docs';

          const preview = await createAndRenderPreview();
          await waitForRender();

          mockChannel.emit.mockClear();
          const story = await preview.storyStore.loadStory({ storyId: 'component-one--a' });
          preview.renderStoryToElement(story, 'story-element' as any);
          await waitForRender();

          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              storyContext: expect.objectContaining({
                args: { foo: 'a' },
              }),
            }),
            'story-element'
          );

          docsRenderer.render.mockClear();
          mockChannel.emit.mockClear();
          emitter.emit(UPDATE_STORY_ARGS, {
            storyId: 'component-one--a',
            updatedArgs: { new: 'arg' },
          });
          await waitForRender();

          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              storyContext: expect.objectContaining({
                args: { foo: 'a', new: 'arg' },
              }),
            }),
            'story-element'
          );
        });
      });
    });
  });

  describe('onPreloadStories', () => {
    it('loads stories', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();
      await waitForRender();

      importFn.mockClear();
      await preview.onPreloadStories({ ids: ['component-two--c'] });
      expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js');
    });

    it('loads legacy docs entries', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();
      await waitForRender();

      importFn.mockClear();
      await preview.onPreloadStories({ ids: ['component-one--docs'] });
      expect(importFn).toHaveBeenCalledWith('./src/ComponentOne.stories.js');
    });

    it('loads modern docs entries', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();
      await waitForRender();

      importFn.mockClear();
      await preview.onPreloadStories({ ids: ['introduction--docs'] });
      expect(importFn).toHaveBeenCalledWith('./src/Introduction.mdx');
    });
    it('loads imports of modern docs entries', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();
      await waitForRender();

      importFn.mockClear();
      await preview.onPreloadStories({ ids: ['introduction--docs'] });
      expect(importFn).toHaveBeenCalledWith('./src/ComponentTwo.stories.js');
    });
  });

  describe('onResetArgs', () => {
    it('emits STORY_ARGS_UPDATED', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { foo: 'new' },
      });

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'new' },
      });

      mockChannel.emit.mockClear();
      emitter.emit(RESET_STORY_ARGS, {
        storyId: 'component-one--a',
        argNames: ['foo'],
      });

      await waitForEvents([STORY_ARGS_UPDATED]);

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a' },
      });
    });

    it('resets a single arg', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();
      const onUpdateArgsSpy = jest.spyOn(preview, 'onUpdateArgs');

      mockChannel.emit.mockClear();
      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { foo: 'new', new: 'value' },
      });
      await waitForEvents([STORY_ARGS_UPDATED]);

      mockChannel.emit.mockClear();
      emitter.emit(RESET_STORY_ARGS, {
        storyId: 'component-one--a',
        argNames: ['foo'],
      });

      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            initialArgs: { foo: 'a' },
            args: { foo: 'a', new: 'value' },
          }),
        }),
        'story-element'
      );

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a', new: 'value' },
      });

      expect(onUpdateArgsSpy).toHaveBeenCalledWith({
        storyId: 'component-one--a',
        updatedArgs: { foo: 'a' },
      });
    });

    it('resets all args after one is updated', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();
      const onUpdateArgsSpy = jest.spyOn(preview, 'onUpdateArgs');

      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { foo: 'new' },
      });
      await waitForEvents([STORY_ARGS_UPDATED]);

      mockChannel.emit.mockClear();
      emitter.emit(RESET_STORY_ARGS, {
        storyId: 'component-one--a',
      });

      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            initialArgs: { foo: 'a' },
            args: { foo: 'a' },
          }),
        }),
        'story-element'
      );

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a' },
      });

      expect(onUpdateArgsSpy).toHaveBeenCalledWith({
        storyId: 'component-one--a',
        updatedArgs: { foo: 'a' },
      });
    });

    it('resets all args', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();
      const onUpdateArgsSpy = jest.spyOn(preview, 'onUpdateArgs');

      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { foo: 'new', new: 'value' },
      });
      await waitForEvents([STORY_ARGS_UPDATED]);

      mockChannel.emit.mockClear();
      emitter.emit(RESET_STORY_ARGS, {
        storyId: 'component-one--a',
      });

      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            initialArgs: { foo: 'a' },
            args: { foo: 'a' },
          }),
        }),
        'story-element'
      );

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a' },
      });

      expect(onUpdateArgsSpy).toHaveBeenCalledWith({
        storyId: 'component-one--a',
        updatedArgs: { foo: 'a', new: undefined },
      });
    });

    it('resets all args when one arg is undefined', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();
      const onUpdateArgsSpy = jest.spyOn(preview, 'onUpdateArgs');

      emitter.emit(UPDATE_STORY_ARGS, {
        storyId: 'component-one--a',
        updatedArgs: { foo: undefined },
      });
      await waitForEvents([STORY_ARGS_UPDATED]);

      mockChannel.emit.mockClear();
      emitter.emit(RESET_STORY_ARGS, {
        storyId: 'component-one--a',
      });

      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: false,
          storyContext: expect.objectContaining({
            initialArgs: { foo: 'a' },
            args: { foo: 'a' },
          }),
        }),
        'story-element'
      );

      await waitForEvents([STORY_ARGS_UPDATED]);
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a' },
      });

      expect(onUpdateArgsSpy).toHaveBeenCalledWith({
        storyId: 'component-one--a',
        updatedArgs: { foo: 'a' },
      });
    });
  });

  describe('on FORCE_RE_RENDER', () => {
    it('rerenders the story with the same args', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      projectAnnotations.renderToDOM.mockClear();
      emitter.emit(FORCE_RE_RENDER);
      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({ forceRemount: false }),
        'story-element'
      );
    });
  });

  describe('on FORCE_REMOUNT', () => {
    beforeEach(() => {
      jest.useFakeTimers();
    });
    afterEach(() => {
      jest.useRealTimers();
    });

    it('remounts the story with the same args', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      mockChannel.emit.mockClear();
      projectAnnotations.renderToDOM.mockClear();
      emitter.emit(FORCE_REMOUNT, { storyId: 'component-one--a' });
      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({ forceRemount: true }),
        'story-element'
      );
    });

    it('aborts render function for initial story', async () => {
      const [gate, openGate] = createGate();

      document.location.search = '?id=component-one--a';
      projectAnnotations.renderToDOM.mockImplementation(async () => gate);
      await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
      await waitForRenderPhase('rendering');

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          forceRemount: true,
          storyContext: expect.objectContaining({
            id: 'component-one--a',
            loaded: { l: 7 },
          }),
        }),
        'story-element'
      );

      mockChannel.emit.mockClear();
      emitter.emit(FORCE_REMOUNT, { storyId: 'component-one--a' });
      await waitForSetCurrentStory();

      // Now let the renderToDOM call resolve
      openGate();
      await waitForRenderPhase('aborted');
      await waitForSetCurrentStory();

      await waitForRenderPhase('rendering');
      expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(2);

      await waitForRenderPhase('playing');
      expect(componentOneExports.a.play).toHaveBeenCalledTimes(1);

      await waitForRenderPhase('completed');
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');

      await waitForQuiescence();
    });
  });

  describe('onSetCurrentStory', () => {
    beforeEach(() => {
      jest.useFakeTimers();
    });
    afterEach(() => {
      jest.useRealTimers();
    });

    it('updates URL', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      emitter.emit(SET_CURRENT_STORY, {
        storyId: 'component-one--b',
        viewMode: 'story',
      });
      await waitForSetCurrentStory();

      expect(history.replaceState).toHaveBeenCalledWith(
        {},
        '',
        'pathname?id=component-one--b&viewMode=story'
      );
    });

    it('emits CURRENT_STORY_WAS_SET', async () => {
      document.location.search = '?id=component-one--a';
      await createAndRenderPreview();

      emitter.emit(SET_CURRENT_STORY, {
        storyId: 'component-one--b',
        viewMode: 'story',
      });
      await waitForSetCurrentStory();

      expect(mockChannel.emit).toHaveBeenCalledWith(CURRENT_STORY_WAS_SET, {
        storyId: 'component-one--b',
        viewMode: 'story',
      });
    });

    it('renders loading error if the story specified does not exist', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      emitter.emit(SET_CURRENT_STORY, {
        storyId: 'random',
        viewMode: 'story',
      });
      await waitForSetCurrentStory();

      await waitForEvents([STORY_MISSING]);
      expect(preview.view.showErrorDisplay).toHaveBeenCalled();
      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING, 'random');
    });

    describe('if called before the preview is initialized', () => {
      it('still renders the selected story, once ready', async () => {
        document.location.search = '';
        // We intentionally are *not* awaiting here
        new PreviewWeb().initialize({ importFn, getProjectAnnotations });

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });

        await waitForEvents([STORY_RENDERED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(CURRENT_STORY_WAS_SET, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        expect(history.replaceState).toHaveBeenCalledWith(
          {},
          '',
          'pathname?id=component-one--b&viewMode=story'
        );
        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_MISSING, 'component-one--b');
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--b');
      });
    });

    describe('if called on a storybook without selection', () => {
      it('sets viewMode to story by default', async () => {
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
        });
        await waitForSetCurrentStory();

        expect(history.replaceState).toHaveBeenCalledWith(
          {},
          '',
          'pathname?id=component-one--b&viewMode=story'
        );
      });
    });

    describe('if the selection is unchanged', () => {
      it('emits STORY_UNCHANGED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_UNCHANGED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_UNCHANGED, 'component-one--a');
      });

      it('does NOT call renderToDOM', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        projectAnnotations.renderToDOM.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        // The renderToDOM would have been async so we need to wait a tick.
        await waitForQuiescence();
        expect(projectAnnotations.renderToDOM).not.toHaveBeenCalled();
      });

      it('does NOT call renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        projectAnnotations.renderToDOM.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        expect(teardownRenderToDOM).not.toHaveBeenCalled();
      });

      describe('while preparing', () => {
        // For https://github.com/storybookjs/storybook/issues/17214
        it('does NOT render a second time in story mode', async () => {
          document.location.search = '?id=component-one--a';

          const [gate, openGate] = createGate();
          const [importedGate, openImportedGate] = createGate();
          importFn
            .mockImplementationOnce(async (...args) => {
              await gate;
              return importFn(...args);
            })
            .mockImplementationOnce(async (...args) => {
              // The second time we `import()` we open the "imported" gate
              openImportedGate();
              await gate;
              return importFn(...args);
            });

          const preview = new PreviewWeb();
          // We can't wait for the initialize function, as it waits for `renderSelection()`
          // which prepares, but it does emit `CURRENT_STORY_WAS_SET` right before that
          preview.initialize({ importFn, getProjectAnnotations });
          await waitForEvents([CURRENT_STORY_WAS_SET]);

          mockChannel.emit.mockClear();
          projectAnnotations.renderToDOM.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--a',
            viewMode: 'story',
          });
          await importedGate;
          // We are blocking import so this won't render yet
          expect(projectAnnotations.renderToDOM).not.toHaveBeenCalled();

          mockChannel.emit.mockClear();
          openGate();
          await waitForRender();

          // We should only render *once*
          expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(1);

          // We should not show an error either
          expect(preview.view.showErrorDisplay).not.toHaveBeenCalled();
        });

        // For https://github.com/storybookjs/storybook/issues/19015
        it('does NOT render a second time in template docs mode', async () => {
          document.location.search = '?id=component-one--docs&viewMode=docs';

          const [gate, openGate] = createGate();
          const [importedGate, openImportedGate] = createGate();
          importFn
            .mockImplementationOnce(async (...args) => {
              await gate;
              return importFn(...args);
            })
            .mockImplementationOnce(async (...args) => {
              // The second time we `import()` we open the "imported" gate
              openImportedGate();
              await gate;
              return importFn(...args);
            });

          const preview = new PreviewWeb();
          // We can't wait for the initialize function, as it waits for `renderSelection()`
          // which prepares, but it does emit `CURRENT_STORY_WAS_SET` right before that
          preview.initialize({ importFn, getProjectAnnotations });
          await waitForEvents([CURRENT_STORY_WAS_SET]);

          mockChannel.emit.mockClear();
          projectAnnotations.renderToDOM.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--docs',
            viewMode: 'docs',
          });
          await importedGate;
          // We are blocking import so this won't render yet
          expect(docsRenderer.render).not.toHaveBeenCalled();

          mockChannel.emit.mockClear();
          openGate();
          await waitForRender();

          // We should only render *once*
          expect(docsRenderer.render).toHaveBeenCalledTimes(1);

          // We should not show an error either
          expect(preview.view.showErrorDisplay).not.toHaveBeenCalled();
        });

        it('does NOT render a second time in standalone docs mode', async () => {
          document.location.search = '?id=introduction--docs&viewMode=docs';

          const [gate, openGate] = createGate();
          const [importedGate, openImportedGate] = createGate();
          importFn
            .mockImplementationOnce(async (...args) => {
              await gate;
              return importFn(...args);
            })
            .mockImplementationOnce(async (...args) => {
              // The second time we `import()` we open the "imported" gate
              openImportedGate();
              await gate;
              return importFn(...args);
            });

          const preview = new PreviewWeb();
          // We can't wait for the initialize function, as it waits for `renderSelection()`
          // which prepares, but it does emit `CURRENT_STORY_WAS_SET` right before that
          preview.initialize({ importFn, getProjectAnnotations });
          await waitForEvents([CURRENT_STORY_WAS_SET]);

          mockChannel.emit.mockClear();
          projectAnnotations.renderToDOM.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'introduction--docs',
            viewMode: 'docs',
          });
          await importedGate;
          // We are blocking import so this won't render yet
          expect(docsRenderer.render).not.toHaveBeenCalled();

          mockChannel.emit.mockClear();
          openGate();
          await waitForRender();

          // We should only render *once*
          expect(docsRenderer.render).toHaveBeenCalledTimes(1);

          // We should not show an error either
          expect(preview.view.showErrorDisplay).not.toHaveBeenCalled();
        });
      });
    });

    describe('when changing story in story viewMode', () => {
      it('calls renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        projectAnnotations.renderToDOM.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        expect(teardownRenderToDOM).toHaveBeenCalled();
      });

      it('updates URL', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        expect(history.replaceState).toHaveBeenCalledWith(
          {},
          '',
          'pathname?id=component-one--b&viewMode=story'
        );
      });

      it('renders preparing state', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        expect(preview.view.showPreparingStory).toHaveBeenCalled();
      });

      it('emits STORY_CHANGED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_CHANGED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_CHANGED, 'component-one--b');
      });

      it('emits STORY_PREPARED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_PREPARED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_PREPARED, {
          id: 'component-one--b',
          parameters: {
            __isArgsStory: false,
            docs: expect.any(Object),
            fileName: './src/ComponentOne.stories.js',
          },
          initialArgs: { foo: 'b' },
          argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
          args: { foo: 'b' },
        });
      });

      it('applies loaders with story context', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForRender();
        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--b',
            parameters: {
              __isArgsStory: false,
              docs: expect.any(Object),
              fileName: './src/ComponentOne.stories.js',
            },
            initialArgs: { foo: 'b' },
            argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
            args: { foo: 'b' },
          })
        );
      });

      it('passes loaded context to renderToDOM', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--b',
              parameters: {
                __isArgsStory: false,
                docs: expect.any(Object),
                fileName: './src/ComponentOne.stories.js',
              },
              globals: { a: 'b' },
              initialArgs: { foo: 'b' },
              argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
              args: { foo: 'b' },
              loaded: { l: 7 },
            }),
          }),
          'story-element'
        );
      });

      it('renders exception if renderToDOM throws', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation(() => {
          throw error;
        });

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders error if the story calls showError', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = { title: 'title', description: 'description' };
        projectAnnotations.renderToDOM.mockImplementation((context) => context.showError(error));

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ERRORED, error);
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({
          message: error.title,
          stack: error.description,
        });
      });

      it('renders exception if the story calls showException', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation((context) =>
          context.showException(error)
        );

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('executes playFunction', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(componentOneExports.b.play).toHaveBeenCalled();
      });

      it('emits STORY_RENDERED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--b');
      });

      it('retains any arg changes', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { foo: 'updated' },
        });
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'updated',
        });

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'updated',
        });

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'updated',
        });
      });

      describe('while story is still rendering', () => {
        it('stops initial story after loaders if running', async () => {
          const [gate, openGate] = createGate();
          componentOneExports.default.loaders[0].mockImplementationOnce(async () => gate);

          document.location.search = '?id=component-one--a';
          await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
          await waitForRenderPhase('loading');

          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();
          await waitForRender();

          // Now let the loader resolve
          openGate({ l: 8 });
          await waitForRender();

          // Story gets rendered with updated args
          expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(1);
          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              forceRemount: true,
              storyContext: expect.objectContaining({
                id: 'component-one--b',
                loaded: { l: 7 },
              }),
            }),
            'story-element'
          );
        });

        it('aborts render for initial story', async () => {
          const [gate, openGate] = createGate();

          document.location.search = '?id=component-one--a';
          projectAnnotations.renderToDOM.mockImplementation(async () => gate);
          await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
          await waitForRenderPhase('rendering');

          mockChannel.emit.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();

          // Now let the renderToDOM call resolve
          openGate();
          await waitForRenderPhase('aborted');
          await waitForSetCurrentStory();

          await waitForRenderPhase('rendering');
          expect(projectAnnotations.renderToDOM).toHaveBeenCalledTimes(2);

          await waitForRenderPhase('playing');
          expect(componentOneExports.a.play).not.toHaveBeenCalled();
          expect(componentOneExports.b.play).toHaveBeenCalled();

          await waitForRenderPhase('completed');
          expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
          expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--b');

          await waitForQuiescence();
        });

        it('aborts play function for initial story', async () => {
          const [gate, openGate] = createGate();
          componentOneExports.a.play.mockImplementationOnce(async () => gate);

          document.location.search = '?id=component-one--a';
          await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
          await waitForRenderPhase('playing');

          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              forceRemount: true,
              storyContext: expect.objectContaining({
                id: 'component-one--a',
                loaded: { l: 7 },
              }),
            }),
            'story-element'
          );

          mockChannel.emit.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();

          // Now let the playFunction call resolve
          openGate();
          await waitForRenderPhase('aborted');
          await waitForSetCurrentStory();

          await waitForRenderPhase('rendering');
          expect(mockChannel.emit).toHaveBeenCalledWith(STORY_CHANGED, 'component-one--b');
          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              forceRemount: true,
              storyContext: expect.objectContaining({
                id: 'component-one--b',
                loaded: { l: 7 },
              }),
            }),
            'story-element'
          );

          await waitForRenderPhase('playing');
          await waitForRenderPhase('completed');
          expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--b');

          // Final story rendered is not emitted for the first story
          await waitForQuiescence();
          expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
        });

        it('reloads page if playFunction fails to abort in time', async () => {
          const [gate] = createGate();
          componentOneExports.a.play.mockImplementationOnce(async () => gate);

          document.location.search = '?id=component-one--a';
          await new PreviewWeb().initialize({ importFn, getProjectAnnotations });
          await waitForRenderPhase('playing');

          expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
            expect.objectContaining({
              forceRemount: true,
              storyContext: expect.objectContaining({
                id: 'component-one--a',
                loaded: { l: 7 },
              }),
            }),
            'story-element'
          );

          mockChannel.emit.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });

          // Wait three ticks without resolving the play function
          await waitForSetCurrentStory();
          await waitForSetCurrentStory();
          await waitForSetCurrentStory();

          expect(global.window.location.reload).toHaveBeenCalled();
          expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_CHANGED, 'component-one--b');
          expect(projectAnnotations.renderToDOM).not.toHaveBeenCalledWith(
            expect.objectContaining({
              storyContext: expect.objectContaining({ id: 'component-one--b' }),
            }),
            undefined
          );
        });
      });
    });

    describe('when changing from story viewMode to docs', () => {
      it('calls renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();

        expect(teardownRenderToDOM).toHaveBeenCalled();
      });

      it('updates URL', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();

        expect(history.replaceState).toHaveBeenCalledWith(
          {},
          '',
          'pathname?id=component-one--docs&viewMode=docs'
        );
      });

      it('renders preparing state', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();

        expect(preview.view.showPreparingDocs).toHaveBeenCalled();
      });

      it('emits STORY_CHANGED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_CHANGED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_CHANGED, 'component-one--docs');
      });

      it('calls view.prepareForDocs', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(preview.view.prepareForDocs).toHaveBeenCalled();
      });

      it('render the docs container with the correct context, template render', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(docsRenderer.render).toHaveBeenCalled();
        expect(docsRenderer.render.mock.calls[0][0].storyById()).toMatchObject({
          id: 'component-one--a',
        });
      });

      it('emits DOCS_RENDERED', async () => {
        document.location.search = '?id=component-one--a';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--docs',
          viewMode: 'docs',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'component-one--docs');
      });
    });

    describe('when changing from docs viewMode to story', () => {
      it('updates URL', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        expect(history.replaceState).toHaveBeenCalledWith(
          {},
          '',
          'pathname?id=component-one--a&viewMode=story'
        );
      });

      it('unmounts docs', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(docsRenderer.unmount).toHaveBeenCalled();
      });

      // NOTE: I am not sure this entirely makes sense but this is the behaviour from 6.3
      it('emits STORY_CHANGED', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_CHANGED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_CHANGED, 'component-one--a');
      });

      it('calls view.prepareForStory', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(preview.view.prepareForStory).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--a',
          })
        );
      });

      it('emits STORY_PREPARED', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForEvents([STORY_PREPARED]);
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_PREPARED, {
          id: 'component-one--a',
          parameters: {
            __isArgsStory: false,
            docs: expect.any(Object),
            fileName: './src/ComponentOne.stories.js',
          },
          initialArgs: { foo: 'a' },
          argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
          args: { foo: 'a' },
        });
      });

      it('applies loaders with story context', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();

        await waitForRender();
        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--a',
            parameters: {
              __isArgsStory: false,
              docs: expect.any(Object),
              fileName: './src/ComponentOne.stories.js',
            },
            initialArgs: { foo: 'a' },
            argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
            args: { foo: 'a' },
          })
        );
      });

      it('passes loaded context to renderToDOM', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--a',
              parameters: {
                __isArgsStory: false,
                docs: expect.any(Object),
                fileName: './src/ComponentOne.stories.js',
              },
              globals: { a: 'b' },
              initialArgs: { foo: 'a' },
              argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
              args: { foo: 'a' },
              loaded: { l: 7 },
            }),
          }),
          'story-element'
        );
      });

      it('renders exception if renderToDOM throws', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation(() => {
          throw error;
        });

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders error if the story calls showError', async () => {
        const error = { title: 'title', description: 'description' };
        projectAnnotations.renderToDOM.mockImplementation((context) => context.showError(error));

        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ERRORED, error);
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({
          message: error.title,
          stack: error.description,
        });
      });

      it('renders exception if the story calls showException', async () => {
        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation((context) =>
          context.showException(error)
        );

        document.location.search = '?id=component-one--docs&viewMode=docs';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('executes playFunction', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(componentOneExports.a.play).toHaveBeenCalled();
      });

      it('emits STORY_RENDERED', async () => {
        document.location.search = '?id=component-one--docs&viewMode=docs';
        await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });
    });
  });

  describe('onStoriesChanged', () => {
    describe('if stories.json endpoint 500s initially', () => {
      it('recovers and renders the story', async () => {
        document.location.search = '?id=component-one--a';
        const err = new Error('sort error');
        mockFetchResult = { status: 500, text: async () => err.toString() };

        const preview = new PreviewWeb();
        await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(
          'sort error'
        );

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect(mockChannel.emit).toHaveBeenCalledWith(CONFIG_ERROR, expect.any(Error));

        mockChannel.emit.mockClear();
        mockFetchResult = { status: 200, json: mockStoryIndex, text: () => 'error text' };
        preview.onStoryIndexChanged();
        await waitForRender();
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });

      it('sets story args from the URL', async () => {
        document.location.search = '?id=component-one--a&args=foo:url';
        const err = new Error('sort error');
        mockFetchResult = { status: 500, text: async () => err.toString() };

        const preview = new PreviewWeb();
        await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(
          'sort error'
        );

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect(mockChannel.emit).toHaveBeenCalledWith(CONFIG_ERROR, expect.any(Error));

        mockChannel.emit.mockClear();
        mockFetchResult = { status: 200, json: mockStoryIndex, text: () => 'error text' };
        preview.onStoryIndexChanged();
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'url',
        });
      });
    });

    describe('when the current story changes', () => {
      const newComponentOneExports = merge({}, componentOneExports, {
        a: { args: { foo: 'edited' } },
      });
      const newImportFn = jest.fn(async (path) => {
        return path === './src/ComponentOne.stories.js'
          ? newComponentOneExports
          : componentTwoExports;
      });

      it('calls renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(teardownRenderToDOM).toHaveBeenCalled();
      });

      it('does not emit STORY_UNCHANGED', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_UNCHANGED, 'component-one--a');
      });

      it('does not emit STORY_CHANGED', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_CHANGED, 'component-one--a');
      });

      it('emits STORY_PREPARED with new annotations', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_PREPARED, {
          id: 'component-one--a',
          parameters: {
            __isArgsStory: false,
            docs: expect.any(Object),
            fileName: './src/ComponentOne.stories.js',
          },
          initialArgs: { foo: 'edited' },
          argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
          args: { foo: 'edited' },
        });
      });

      it('emits STORY_ARGS_UPDATED with new args', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
          storyId: 'component-one--a',
          args: { foo: 'edited' },
        });
      });

      it('applies loaders with story context', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        componentOneExports.default.loaders[0].mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(componentOneExports.default.loaders[0]).toHaveBeenCalledWith(
          expect.objectContaining({
            id: 'component-one--a',
            parameters: {
              __isArgsStory: false,
              docs: expect.any(Object),
              fileName: './src/ComponentOne.stories.js',
            },
            initialArgs: { foo: 'edited' },
            argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
            args: { foo: 'edited' },
          })
        );
      });

      it('passes loaded context to renderToDOM', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--a',
              parameters: {
                __isArgsStory: false,
                docs: expect.any(Object),
                fileName: './src/ComponentOne.stories.js',
              },
              globals: { a: 'b' },
              initialArgs: { foo: 'edited' },
              argTypes: { foo: { name: 'foo', type: { name: 'string' } } },
              args: { foo: 'edited' },
              loaded: { l: 7 },
            }),
          }),
          'story-element'
        );
      });

      it('retains the same delta to the args', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { foo: 'updated' },
        });
        await waitForRender();

        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--a',
              args: { foo: 'updated' },
            }),
          }),
          'story-element'
        );
      });

      it('renders exception if renderToDOM throws', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation(() => {
          throw error;
        });

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('renders error if the story calls showError', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = { title: 'title', description: 'description' };
        projectAnnotations.renderToDOM.mockImplementation((context) => context.showError(error));

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ERRORED, error);
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith({
          message: error.title,
          stack: error.description,
        });
      });

      it('renders exception if the story calls showException', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        const error = new Error('error');
        projectAnnotations.renderToDOM.mockImplementation((context) =>
          context.showException(error)
        );

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_THREW_EXCEPTION, serializeError(error));
        expect(preview.view.showErrorDisplay).toHaveBeenCalledWith(error);
      });

      it('executes playFunction', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        componentOneExports.a.play.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(componentOneExports.a.play).toHaveBeenCalled();
      });

      it('emits STORY_RENDERED', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });
    });

    describe('when the current story changes importPath', () => {
      const newImportFn = jest.fn(async (path) => ({ ...componentOneExports }));

      const newStoryIndex = {
        v: 4,
        entries: {
          ...storyIndex.entries,
          'component-one--a': {
            ...storyIndex.entries['component-one--a'],
            importPath: './src/ComponentOne-new.stories.js',
          },
        },
      };
      beforeEach(() => {
        newImportFn.mockClear();
      });

      it('re-imports the component', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });
        await waitForRender();

        expect(newImportFn).toHaveBeenCalledWith('./src/ComponentOne-new.stories.js');
      });

      describe('if it was previously rendered', () => {
        beforeEach(() => {
          jest.useFakeTimers();
        });
        afterEach(() => {
          jest.useRealTimers();
        });
        it('is reloaded when it is re-selected', async () => {
          document.location.search = '?id=component-one--a';
          const preview = await createAndRenderPreview();

          mockChannel.emit.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--b',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();
          await waitForRender();

          preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });

          mockChannel.emit.mockClear();
          emitter.emit(SET_CURRENT_STORY, {
            storyId: 'component-one--a',
            viewMode: 'story',
          });
          await waitForSetCurrentStory();
          await waitForRender();
          expect(newImportFn).toHaveBeenCalledWith('./src/ComponentOne-new.stories.js');
        });
      });
    });

    describe('when the current story has not changed', () => {
      const newComponentTwoExports = { ...componentTwoExports };
      const newImportFn = jest.fn(async (path) => {
        return path === './src/ComponentOne.stories.js'
          ? componentOneExports
          : newComponentTwoExports;
      });

      it('does NOT call renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForEvents([STORY_UNCHANGED]);

        expect(teardownRenderToDOM).not.toHaveBeenCalled();
      });

      it('emits STORY_UNCHANGED', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForEvents([STORY_UNCHANGED]);

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_UNCHANGED, 'component-one--a');
        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_CHANGED, 'component-one--a');
      });

      it('clears preparing state', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        (preview.view.showMain as jest.Mock).mockClear();
        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForEvents([STORY_UNCHANGED]);

        expect(preview.view.showMain).toHaveBeenCalled();
      });

      it('does not re-render the story', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForQuiescence();

        expect(projectAnnotations.renderToDOM).not.toHaveBeenCalled();
        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });
    });

    describe('when another (not current) story changes', () => {
      beforeEach(() => {
        jest.useFakeTimers();
      });
      afterEach(() => {
        jest.useRealTimers();
      });
      const newComponentOneExports = merge({}, componentOneExports, {
        a: { args: { bar: 'edited' }, argTypes: { bar: { type: { name: 'string' } } } },
      });
      const newImportFn = jest.fn(async (path) => {
        return path === './src/ComponentOne.stories.js'
          ? newComponentOneExports
          : componentTwoExports;
      });
      it('retains the same delta to the args', async () => {
        // Start at Story A
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        // Change A's args
        mockChannel.emit.mockClear();
        emitter.emit(UPDATE_STORY_ARGS, {
          storyId: 'component-one--a',
          updatedArgs: { foo: 'updated' },
        });
        await waitForRender();

        // Change to story B
        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--b',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'updated',
        });

        // Update story A's args via HMR
        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        // Change back to Story A
        mockChannel.emit.mockClear();
        emitter.emit(SET_CURRENT_STORY, {
          storyId: 'component-one--a',
          viewMode: 'story',
        });
        await waitForSetCurrentStory();
        await waitForRender();
        expect(preview.storyStore.args.get('component-one--a')).toEqual({
          foo: 'updated',
          bar: 'edited',
        });

        expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
          expect.objectContaining({
            forceRemount: true,
            storyContext: expect.objectContaining({
              id: 'component-one--a',
              args: { foo: 'updated', bar: 'edited' },
            }),
          }),
          'story-element'
        );
      });
    });

    describe('if the story no longer exists', () => {
      const { a, ...componentOneExportsWithoutA } = componentOneExports;
      const newImportFn = jest.fn(async (path) => {
        return path === './src/ComponentOne.stories.js'
          ? componentOneExportsWithoutA
          : componentTwoExports;
      });

      const newStoryIndex = {
        v: 4,
        entries: {
          'component-one--b': storyIndex.entries['component-one--b'],
        },
      };

      it('calls renderToDOMs teardown', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });
        await waitForEvents([STORY_MISSING]);

        expect(teardownRenderToDOM).toHaveBeenCalled();
      });

      it('renders loading error', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });
        await waitForEvents([STORY_MISSING]);

        expect(preview.view.showErrorDisplay).toHaveBeenCalled();
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_MISSING, 'component-one--a');
      });

      it('does not re-render the story', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        projectAnnotations.renderToDOM.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });
        await waitForQuiescence();

        expect(projectAnnotations.renderToDOM).not.toHaveBeenCalled();
        expect(mockChannel.emit).not.toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });

      it('re-renders the story if it is readded', async () => {
        document.location.search = '?id=component-one--a';
        const preview = await createAndRenderPreview();

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn: newImportFn, storyIndex: newStoryIndex });
        await waitForEvents([STORY_MISSING]);

        mockChannel.emit.mockClear();
        preview.onStoriesChanged({ importFn, storyIndex });
        await waitForRender();
        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });
    });

    describe('when a standalone docs file changes', () => {
      const newStandaloneDocsExports = { default: jest.fn() };

      const newImportFn = jest.fn(async (path) => {
        return path === './src/Introduction.mdx' ? newStandaloneDocsExports : importFn(path);
      });

      it('renders with the generated docs parameters', async () => {
        document.location.search = '?id=introduction--docs&viewMode=docs';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();
        docsRenderer.render.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(docsRenderer.render).toHaveBeenCalledWith(
          expect.any(Object),
          expect.objectContaining({
            page: newStandaloneDocsExports.default,
            renderer: projectAnnotations.parameters.docs.renderer,
          }),
          'docs-element',
          expect.any(Function)
        );
      });

      it('emits DOCS_RENDERED', async () => {
        document.location.search = '?id=introduction--docs&viewMode=docs';
        const preview = await createAndRenderPreview();
        mockChannel.emit.mockClear();

        preview.onStoriesChanged({ importFn: newImportFn });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(DOCS_RENDERED, 'introduction--docs');
      });
    });
  });

  describe('onGetProjectAnnotationsChanged', () => {
    describe('if initial getProjectAnnotations threw', () => {
      it('recovers and renders the story', async () => {
        document.location.search = '?id=component-one--a';

        const err = new Error('meta error');
        const preview = new PreviewWeb();
        await expect(
          preview.initialize({
            importFn,
            getProjectAnnotations: () => {
              throw err;
            },
          })
        ).rejects.toThrow(err);

        preview.onGetProjectAnnotationsChanged({ getProjectAnnotations });
        await waitForRender();

        expect(mockChannel.emit).toHaveBeenCalledWith(STORY_RENDERED, 'component-one--a');
      });

      it('sets globals from the URL', async () => {
        document.location.search = '?id=*&globals=a:c';

        const err = new Error('meta error');
        const preview = new PreviewWeb();
        await expect(
          preview.initialize({
            importFn,
            getProjectAnnotations: () => {
              throw err;
            },
          })
        ).rejects.toThrow(err);

        preview.onGetProjectAnnotationsChanged({ getProjectAnnotations });
        await waitForRender();

        expect(preview.storyStore.globals!.get()).toEqual({ a: 'c' });
      });
    });

    describe('with no selection', () => {
      // eslint-disable-next-line jest/expect-expect
      it('does not error', async () => {
        const preview = await createAndRenderPreview();
        await preview.onGetProjectAnnotationsChanged({
          getProjectAnnotations: newGetProjectAnnotations,
        });
      });
    });

    it('shows an error the new value throws', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      const err = new Error('error getting meta');
      await expect(
        preview.onGetProjectAnnotationsChanged({
          getProjectAnnotations: () => {
            throw err;
          },
        })
      ).rejects.toThrow(err);

      expect(preview.view.showErrorDisplay).toHaveBeenCalled();
      expect(mockChannel.emit).toHaveBeenCalledWith(CONFIG_ERROR, err);
    });

    const newGlobalDecorator = jest.fn((s) => s());
    const newGetProjectAnnotations = () => {
      return {
        ...projectAnnotations,
        args: { global: 'added' },
        globals: { a: 'edited' },
        decorators: [newGlobalDecorator],
      };
    };

    it('updates globals to their new values', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      expect(preview.storyStore.globals!.get()).toEqual({ a: 'edited' });
    });

    it('emits SET_GLOBALS with new values', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      await waitForEvents([SET_GLOBALS]);
      expect(mockChannel.emit).toHaveBeenCalledWith(SET_GLOBALS, {
        globals: { a: 'edited' },
        globalTypes: {},
      });
    });

    it('updates args to their new values', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      expect(preview.storyStore.args.get('component-one--a')).toEqual({
        foo: 'a',
        global: 'added',
      });
    });

    it('emits SET_STORY_ARGS with new values', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      expect(mockChannel.emit).toHaveBeenCalledWith(STORY_ARGS_UPDATED, {
        storyId: 'component-one--a',
        args: { foo: 'a', global: 'added' },
      });
    });

    it('calls renderToDOMs teardown', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      projectAnnotations.renderToDOM.mockClear();
      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      expect(teardownRenderToDOM).toHaveBeenCalled();
    });

    it('rerenders the current story with new global meta-generated context', async () => {
      document.location.search = '?id=component-one--a';
      const preview = await createAndRenderPreview();

      projectAnnotations.renderToDOM.mockClear();
      mockChannel.emit.mockClear();
      preview.onGetProjectAnnotationsChanged({ getProjectAnnotations: newGetProjectAnnotations });
      await waitForRender();

      expect(projectAnnotations.renderToDOM).toHaveBeenCalledWith(
        expect.objectContaining({
          storyContext: expect.objectContaining({
            args: { foo: 'a', global: 'added' },
            globals: { a: 'edited' },
          }),
        }),
        'story-element'
      );
    });
  });

  describe('onKeydown', () => {
    it('emits PREVIEW_KEYDOWN for regular elements', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();

      preview.onKeydown({
        target: { tagName: 'div', getAttribute: jest.fn().mockReturnValue(null) },
      } as any);

      expect(mockChannel.emit).toHaveBeenCalledWith(PREVIEW_KEYDOWN, expect.objectContaining({}));
    });

    it('does not emit PREVIEW_KEYDOWN for input elements', async () => {
      document.location.search = '?id=component-one--docs&viewMode=docs';
      const preview = await createAndRenderPreview();

      preview.onKeydown({
        target: { tagName: 'input', getAttribute: jest.fn().mockReturnValue(null) },
      } as any);

      expect(mockChannel.emit).not.toHaveBeenCalledWith(
        PREVIEW_KEYDOWN,
        expect.objectContaining({})
      );
    });
  });

  describe('extract', () => {
    // NOTE: if you are using storyStoreV6, and your `preview.js` throws, we do not currently
    // detect it (as we do not wrap the import of `preview.js` in a `try/catch`). The net effect
    // of that is that the `PreviewWeb`/`StoryStore` end up in an uninitalized state.
    it('throws an error if the preview is uninitialized', async () => {
      const preview = new PreviewWeb();
      await expect(preview.extract()).rejects.toThrow(/Failed to initialize/);
    });

    it('throws an error if preview.js throws', async () => {
      const err = new Error('meta error');
      const preview = new PreviewWeb();
      await expect(
        preview.initialize({
          importFn,
          getProjectAnnotations: () => {
            throw err;
          },
        })
      ).rejects.toThrow(err);

      await expect(preview.extract()).rejects.toThrow(err);
    });

    it('shows an error if the stories.json endpoint 500s', async () => {
      const err = new Error('sort error');
      mockFetchResult = { status: 500, text: async () => err.toString() };

      const preview = new PreviewWeb();
      await expect(preview.initialize({ importFn, getProjectAnnotations })).rejects.toThrow(
        'sort error'
      );

      await expect(preview.extract()).rejects.toThrow('sort error');
    });

    it('waits for stories to be cached', async () => {
      const [gate, openGate] = createGate();

      const gatedImportFn = async (path) => {
        await gate;
        return importFn(path);
      };

      const preview = await createAndRenderPreview({ importFn: gatedImportFn });

      let extracted = false;
      preview.extract().then(() => {
        extracted = true;
      });

      expect(extracted).toBe(false);

      openGate();
      await new Promise((r) => setTimeout(r, 0)); // Let the promise resolve
      expect(extracted).toBe(true);

      expect(await preview.extract()).toMatchInlineSnapshot(`
        Object {
          "component-one--a": Object {
            "argTypes": Object {
              "foo": Object {
                "name": "foo",
                "type": Object {
                  "name": "string",
                },
              },
            },
            "args": Object {
              "foo": "a",
            },
            "component": undefined,
            "componentId": "component-one",
            "id": "component-one--a",
            "initialArgs": Object {
              "foo": "a",
            },
            "kind": "Component One",
            "name": "A",
            "parameters": Object {
              "__isArgsStory": false,
              "docs": Object {
                "container": [MockFunction],
                "page": [MockFunction],
                "renderer": [Function],
              },
              "fileName": "./src/ComponentOne.stories.js",
            },
            "story": "A",
            "subcomponents": undefined,
            "title": "Component One",
          },
          "component-one--b": Object {
            "argTypes": Object {
              "foo": Object {
                "name": "foo",
                "type": Object {
                  "name": "string",
                },
              },
            },
            "args": Object {
              "foo": "b",
            },
            "component": undefined,
            "componentId": "component-one",
            "id": "component-one--b",
            "initialArgs": Object {
              "foo": "b",
            },
            "kind": "Component One",
            "name": "B",
            "parameters": Object {
              "__isArgsStory": false,
              "docs": Object {
                "container": [MockFunction],
                "page": [MockFunction],
                "renderer": [Function],
              },
              "fileName": "./src/ComponentOne.stories.js",
            },
            "story": "B",
            "subcomponents": undefined,
            "title": "Component One",
          },
          "component-one--e": Object {
            "argTypes": Object {},
            "args": Object {},
            "component": undefined,
            "componentId": "component-one",
            "id": "component-one--e",
            "initialArgs": Object {},
            "kind": "Component One",
            "name": "E",
            "parameters": Object {
              "__isArgsStory": false,
              "docs": Object {
                "page": [MockFunction],
                "renderer": [Function],
              },
              "fileName": "./src/ExtraComponentOne.stories.js",
            },
            "playFunction": undefined,
            "story": "E",
            "subcomponents": undefined,
            "title": "Component One",
          },
          "component-two--c": Object {
            "argTypes": Object {
              "foo": Object {
                "name": "foo",
                "type": Object {
                  "name": "string",
                },
              },
            },
            "args": Object {
              "foo": "c",
            },
            "component": undefined,
            "componentId": "component-two",
            "id": "component-two--c",
            "initialArgs": Object {
              "foo": "c",
            },
            "kind": "Component Two",
            "name": "C",
            "parameters": Object {
              "__isArgsStory": false,
              "docs": Object {
                "renderer": [Function],
              },
              "fileName": "./src/ComponentTwo.stories.js",
            },
            "playFunction": undefined,
            "story": "C",
            "subcomponents": undefined,
            "title": "Component Two",
          },
        }
      `);
    });
  });
});
