storybook icon indicating copy to clipboard operation
storybook copied to clipboard

addon-interactions: canvasElement returns empty #root div element

Open Garine519 opened this issue 2 years ago • 4 comments

Describe the bug While trying to test my Vue 2 component with addon-interactions, the canvasElement returns an empty #root div element. I can see the story rendered in the DOM, but canvasElement does not include the nested story element.

It's kind of weird. when I switch between stories, it works. only on the first load does it fail to find the story element.

import AppButton, { allowedStates, allowedSizes, allowedTypes } from '@/components/AppButton.vue';
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export default {
  title: 'Components/AppButton',
  component: AppButton,
  argTypes: {
    default: { control: { type: 'text' } },
    icon: { control: { type: 'text' } },
    size: { options: allowedSizes, control: { type: 'select' } },
    state: { options: allowedStates, control: { type: 'select' } },
    type: { options: allowedTypes, control: { type: 'select' } },
    click: { action: 'click' }
  }
};

const Template = (args, { argTypes }) => ({
  props: Object.keys(args),
  components: { AppButton },
  template:
  `<app-button v-bind="$props" @click="click">
    <template v-if="$props.default"><div v-html="$props.default" /></template>
    <template v-if="$props.icon" #icon><div v-html="$props.icon" /></template>
  </app-button>`
});

export const Default = Template.bind({});
Default.args = {
  state: 'primary',
  size: 'medium',
  type: 'button',
  default: '<div>Hello</div>'
};

Default.play = async({ args, canvasElement }) => {
  // Starts querying the component from its root element
  const canvas = within(canvasElement);
  console.log(canvasElement); // <div id="root"></div> <================================= EMPTY ELEMENT
  const button = await canvas.queryByTestId('app-button');
  await expect(button).toBeTruthy();
  await userEvent.click(button);
  await expect(within(button).getByText('Hello')).toBeTruthy();
};

System Environment Info: System: OS: macOS 12.2 CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz Binaries: Node: 14.15.1 - /usr/local/bin/node npm: 6.14.8 - /usr/local/bin/npm Browsers: Chrome: 103.0.5060.114 Firefox: 99.0 Safari: 15.3 npmPackages: @storybook/addon-actions: ^6.5.9 => 6.5.9 @storybook/addon-essentials: ^6.5.9 => 6.5.9 @storybook/addon-interactions: ^6.5.9 => 6.5.9 @storybook/addon-links: ^6.5.9 => 6.5.9 @storybook/addon-postcss: ^2.0.0 => 2.0.0 @storybook/jest: 0.0.10 => 0.0.10 @storybook/testing-library: 0.0.13 => 0.0.13 @storybook/vue: ^6.5.9 => 6.5.9

Additional context hope this video helps. https://user-images.githubusercontent.com/37899422/177896735-ea92547a-a22f-49a4-b3a4-ed00d405055e.mov

Garine519 avatar Jul 08 '22 01:07 Garine519

Have the same issue. Not sure if helps, but I also have a global decorator to wrap the components in the storybook with vuetify stuff,

// preview.js
addDecorator(() => ({
  vuetify: <config object>,
  template: `
    <v-app>
      <v-main>
        <v-container fluid>
          <story />
        </v-container>
      </v-main>
    </v-app>
    `,
}));

//  default preview.js stuffs...
// main.js
module.exports = {
  stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
  framework: '@storybook/vue',
  features: {
    interactionsDebugger: true, // 👈 Enable playback controls
  },
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.scss$/,
      use: ['style-loader', 'css-loader', 'sass-loader'],
      include: path.resolve(__dirname, '../'),
    });

    return config;
  },
};

system: mac m1 monterey 12.4 npm 8.10.0 node 16.14.2 brave v1.41.96

deps: @storybook/addon-actions: ^6.5.9 @storybook/addon-essentials: ^6.5.9 @storybook/addon-interactions: ^6.5.9 @storybook/addon-links: ^6.5.9 @storybook/builder-webpack4: ^6.5.9 @storybook/jest: ^0.0.10 @storybook/manager-webpack4: ^6.5.9 @storybook/test-runner: ^0.5.0 @storybook/testing-library: ^0.0.13 @storybook/vue: ^6.5.9

edit: add main and preview.js files

Ribeiro-Tiago avatar Jul 18 '22 13:07 Ribeiro-Tiago

Same issue here, Vue 2. Using Nuxt (and nuxt/storybook module), no Vuetify. My config is very different from both of yours and I don't see any common factor except for Vue 2.

I tried some debugging, here's what I've found so far:

  1. canvasElement comes from here: https://github.com/storybookjs/storybook/blob/next/code/lib/preview-web/src/render/StoryRender.ts#L145
  2. There is a suspicious FIXME, but it's talking at a level I don't understand the system.
  3. On initial page load, canvasElement is set to an empty root div on the line above.
  4. At the time this happens, that root div is in the DOM -- but it's still empty.
  5. When the component is rendered on the page, that empty root div is replaced, but canvasElement is not reset.
  6. Once canvasElement has been replaced (e.g. with another story), that same root div canvasElement is re-used, which is why it works when navigating between stories.
  7. To figure out why the root div is replaced on page load, you'd have to dig into story rendering, and since that's decoupled from the code I've already dug into it will be hard for me to diagnose further without help.
  8. Likewise, perhaps it's intended behaviour that the div is replaced on initial load, and the bug is that canvasElement should be replaced when that happens. Again, that will require someone with knowledge of the system to look into it.
  9. It's dispiriting that no one has looked into this in a month. Without a more experienced person helping with the above two points, it's likely I won't be able to adopt storybook interaction tests at all.

bmulholland avatar Aug 03 '22 10:08 bmulholland

Hey everyone, thanks a lot for opening this issue and adding so much information here. I can confirm this issue is happening in @storybook/vue but not in @storybook/vue3(works correctly there). We'll have to investigate what's going on, so please bare with us :)

For anyone willing to dive deep into this (and we'd very much appreciate a contribution): My hunch is that there might be a slight problem in the way the Vue2 app (which wraps the story) is bootstrapped, and that might take a couple extra rendering cycles to get things going. Once the Vue2 app is bootstrapped, it's shared between story sessions, which is why it works when you visit from another story, but not when you deeplink to the story. cc @prashantpalikhe

What to do for now

We highly encourage users to use const canvas = within(canvasElement) because it's a way for us to ensure that the play function will work correctly in all Storybook modes. However, it's totally possible to not use it, and instead use screen, which, instead of searching for elements from within the div that wraps the story, it searches for elements in document.body.

I'm sure you can get your interaction tests to work by using screen instead, so here's an example to help visualize:

from:

import { within, userEvent } from '@storybook/testing-library';

LoggedIn.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement);
  const loginButton = await canvas.getByRole('button', { name: /Log in/i });
  await userEvent.click(loginButton);
};

to:

import { within, userEvent } from '@storybook/testing-library';
import { screen, userEvent } from '@storybook/testing-library';

LoggedIn.play = async () => {
  const loginButton = await screen.getByRole('button', { name: /Log in/i });
  await userEvent.click(loginButton);
};

I hope this helps for now, while we find an ultimate solution. Thanks!

ps: you might get a warning in the logs saying "do not use screen", but please ignore them for now!

yannbf avatar Aug 05 '22 18:08 yannbf

Thanks for the workaround @yannbf. I had thought of something similar but didn't know enough about supported queries to make it work. Putting the two together, I think I came up with an even better workaround: just fetch the canvasElement manually.

  const canvasElement = document.querySelector("#root") // instead of an arg
  const canvas = within(canvasElement) // as usual

What I like especially about this one is it should be functionally equivalent (I assume) to expected behaviour, plus switching to the recommended approach (when fixed) will be trivial (remove a line, add an arg).

bmulholland avatar Aug 08 '22 08:08 bmulholland

Thanks for the workaround @yannbf. I had thought of something similar but didn't know enough about supported queries to make it work. Putting the two together, I think I came up with an even better workaround: just fetch the canvasElement manually.

  const canvasElement = document.querySelector("#root") // instead of an arg
  const canvas = within(canvasElement) // as usual

What I like especially about this one is it should be functionally equivalent (I assume) to expected behaviour, plus switching to the recommended approach (when fixed) will be trivial (remove a line, add an arg).

Sounds perfect, @bmulholland ! Thanks for the suggestion!

yannbf avatar Aug 10 '22 05:08 yannbf

@bmulholland Thanks for the suggestion, I have been pulling my hair out for the past couple days trying to solve this one until i found this thread. I ended up needing to add a timeout like this to get it working. Super hacky! But works for now.

const canvasElement = document.querySelector('#root')
await new Promise((resolve) => setTimeout(resolve, 0))
const canvas = within(canvasElement)

fazulk avatar Sep 09 '22 06:09 fazulk

Just as a heads-up for users of (as of writing in alpha 7.0): In storybook 7.0 we renamed this id to #storybook-root.

ndelangen avatar Sep 09 '22 09:09 ndelangen

Can confirm it only worked for me with the timeout solution by @fazulk

ShayDavidson avatar Oct 26 '22 11:10 ShayDavidson

Just awaiting for the promise works for me, no need to look for element

Loading.play = async ({ canvasElement }) => {
  // eslint-disable-next-line no-promise-executor-return
  await new Promise(resolve => setTimeout(resolve, 0));
  const canvas = within(canvasElement);
  expect(canvas.getByText(props.loading.title)).toBeInTheDocument();
  expect(canvas.getByTestId('spinner')).toBeInTheDocument();
  expect(canvas.getByLabelText('default-progressbar')).toBeInTheDocument();
};

alexandprivate avatar Mar 24 '23 15:03 alexandprivate

I am having the same issue with #storybook-root being empty with React 18 and Storybook 7. @alexandprivate your solution was close, but I wasn't able to resolve it with a 0 value. I had to implement at least a 1 second delay, but I was concerned it might be an issue of async that could vary. Figured if the addon author comes along he might prefer this solution:

  play: async ({ canvasElement, args }) => {
    await delayForCanvas(canvasElement);
    const canvas = await within(canvasElement);
}

export const getTriangularNumber = (seed: number) => (seed * (seed + 1)) / 2;

export const delayForCanvas = async (canvasElement: HTMLElement) => {
  let backOff = 0;
  while (!canvasElement.innerHTML) {
    backOff += 1;
    if (backOff > 100) {
      throw new Error(
        "canvasElement.innerHTML did not populate after 100 increasing delays"
      );
    }
    // eslint-disable-next-line no-loop-func
    await new Promise((resolve) =>
      setTimeout(resolve, getTriangularNumber(backOff))
    );
  }
};

lancegliser avatar Apr 04 '23 02:04 lancegliser

I traced this back to the code/lib/preview-api/src/modules/store/csf/prepareStory.tss playFunction, not addon-interactions. Hopefully a maintain can review and help move us all along.

lancegliser avatar Apr 07 '23 03:04 lancegliser

Not sure if this is really resolved. We've hit this bug when using Vite - had to revert to using screen.

a-c-m avatar Feb 29 '24 12:02 a-c-m

Was able to fix a similar issue by doing the following instead of using the canvasElement

const canvas = within(document.body);
await expect(canvas.getByTestId('modal__welcome__confirm')).toHaveTextContent('Button Text');

Ameerplus avatar Feb 29 '24 13:02 Ameerplus