percy / percy-puppeteer

Visual testing with Puppeteer and Percy
https://percy.io
MIT License
49 stars 16 forks source link

executing percySnapshot on multiple pages #52

Closed OriR closed 5 years ago

OriR commented 5 years ago

Hey there!

I have a really long list of tests (~1000) and I wouldn't want to run them synchronously, it would take a really long time.

That's why I'm creating multiple pages on the same browser instance (this is different from the example repo in that the browser object is recreated for each test, it probably doesn't matter because I tried creating a new browser instance for each async operation).

Unfortunately, whenever I try to run the snapshots for multiple pages I get this kind of error:

UnhandledPromiseRejectionWarning: Error: Request is already handled!
djones commented 5 years ago

Hi @OriR,

Thanks for reporting this one!

Would you be able to share a little more about your setup? I would love to see the code which is executing these tests and the Percy snapshot calls. Ideally we can get something reproducible going so we can narrow in on a fix.

Thanks, David.

OriR commented 5 years ago

My setup is basically running puppeteer on a storybook instance, I'm aware of the percy-storybook SDK, but it's not enough for me, I need more control over each story and the ability to interact with it.

This is the code I'm using right now that runs puppeteer: (The comments are me trying to use puppeteer-cluster to achieve the parallelization but they have the same result, though a bit slower since the page management there is much more generic than what I wrote 😅 )

const os = require('os');
const queryString = require('query-string');
const puppeteer = require('puppeteer');
// const { Cluster } = require('puppeteer-cluster');
const { percySnapshot } = require('@percy/puppeteer');
const config = require('../visual.regression.config.js');

const baseURL = process.env.TESTS_URL || 'http://localhost:6006/';

(async () => {
  const snapshot = async (page, story, step = '') => {
    const snapshotName = encodeURIComponent(
      `${story.selectedKind.replace(/\//g, '-')}-${story.selectedStory.replace(
        /\//g,
        '-'
      )}${step.replace(/\//g, '-')}`
    );

    if (config.snapshots) {
      if (config.snapshots.puppeteer) {
        console.log('Snapshotting with Puppeteer - ', snapshotName);
        await page.screenshot({ path: `${snapshotName}.png` });
      }

      if (config.snapshots.percy) {
        console.log('Snapshotting with Percy - ', snapshotName);
        await percySnapshot(page, `${snapshotName}`);
      }
    }
  };

  const actions = {
    click: async ({ story, frame, data }) => {
      const element = await frame.$(data.locator);
      await element.click();
    },
    mouseUp: async ({ story, frame, data }) => {
      const element = await frame.$(data.locator);
      await element.click();
    },
    mouseDown: async ({ story, frame, data }) => {
      const element = await frame.$(data.locator);
      await element.click();
    },
    focus: async ({ story, frame, data }) => {
      const element = await frame.$(data.locator);
      await element.click();
    },
    hover: async ({ story, frame, data }) => {
      const element = await frame.$(data.locator);
      await element.click();
    },
    snapshot: async ({ story, page, data }) => {
      console.log('Snapshotting Step - ', { ...story, step: data.name });
      await snapshot(page, story, data.name);
    },
    setValue: async ({ story, frame, page, data }) => {
      await frame.focus(data.locator);
      await page.keyboard.type(data.value);
    },
    eval: async ({ story, frame, data }) => {
      await frame.evaluate(data.script);
    },
    wait: async ({ story, frame, data }) => {
      await frame.wait(data.locatorOrMs);
    }
  };

  const browser = await puppeteer.launch({
    headless: config.headless,
    timeout: 10000,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });

  const page = await browser.newPage();

  console.log('Navigating to Storybook root...');
  await page.goto(baseURL);
  await page.setViewport(config.viewport);

  console.log('Fetching stories...');
  const stories = await page.evaluate(() => {
    return new Promise(resolve => {
      const i = setInterval(() => {
        if (window['__SB-Stories__']) {
          clearInterval(i);
          resolve(window['__SB-Stories__']);
        }
      }, 1000);
    });
  });
  console.log('Fetched stories', stories);
  const matchers = {
    string: (matcher, story) => {
      return story.selectedKind.includes(matcher) || story.selectedStory.includes(matcher);
    },
    function: (matcher, story) => {
      return matcher(story);
    },
    regexp: (matcher, story) => {
      return !!story.selectedKind.match(matcher) || !!story.selectedStory.match(matcher);
    }
  };
  const countSteps = stories =>
    stories.length +
    stories.reduce((sum, story) => sum + (story.steps ? story.steps.length : 0), 0);

  const filteredStories = config.include
    ? stories.filter(story => config.include.some(i => matchers[i.constructor.name](i, story)))
    : stories;
  const numOfBuckets = Math.max(config.parallelization || os.cpus().length, 1);
  const storyBuckets = new Array(numOfBuckets).fill(undefined).map(_ => []);
  const maxStoriesPerBucket = Math.floor(countSteps(filteredStories) / numOfBuckets);

  filteredStories.forEach(story => {
    storyBuckets.some((bucket, bucketIndex) => {
      // If it's the last bucket - we have to put it there.
      if (bucketIndex === storyBuckets.length - 1) {
        bucket.push(story);
        return true;
      }

      const bucketSize = countSteps(bucket);
      if (bucketSize >= maxStoriesPerBucket) {
        return false;
      }

      if (bucketSize !== 0 && story.steps && story.steps.length >= maxStoriesPerBucket) {
        return false;
      }

      bucket.push(story);
      return true;
    });
  });

  console.log(`Using ${numOfBuckets} cores...`);
  console.log(
    `The core division is: ${storyBuckets.map(
      (b, i) => `Core ${i + 1} - ${b.length} stories (total steps is ${countSteps(b)})`
    )}`
  );

  // const cluster = await Cluster.launch({
  //   concurrency: Cluster.CONCURRENCY_PAGE,
  //   maxConcurrency: numOfBuckets,
  //   puppeteerOptions: {
  //     headless: config.headless,
  //     timeout: 10000
  //   }
  // });

  // cluster.task(async ({ page, data: { steps, ...rest } }) => {
  //   await page.setViewport(config.viewport);
  //   await page.goto(`${baseURL}?${queryString.stringify(rest)}`);

  //   console.log('Removing Storybook decoractions...');
  //   await page.keyboard.down('Shift');
  //   await page.keyboard.down('Control');
  //   await page.keyboard.press('KeyX');
  //   await page.keyboard.press('KeyC');
  //   await page.keyboard.up('Control');
  //   await page.keyboard.up('Shift');
  //   console.log('Removed Storybook decoractions');

  //   console.log('Snapshotting Story - ', rest);
  //   await snapshot(page, rest);

  //   if (steps) {
  //     console.log('Snapshotting Steps - ', steps);
  //     const frame = page.frames().find(frame => frame.name() === 'storybook-preview-iframe');

  //     for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
  //       const step = steps[stepIndex];
  //       await actions[step.type]({ story: rest, data: step.data, page, frame });
  //     }
  //   }
  // });

  // filteredStories.forEach(story => cluster.queue(story));

  // await cluster.idle();
  // await cluster.close();
  await Promise.all(
    storyBuckets.map(async stories => {
      const page = await browser.newPage();
      await page.setViewport(config.viewport);

      for (let storyIndex = 0; storyIndex < stories.length; storyIndex++) {
        const { steps, ...rest } = stories[storyIndex];

        await page.goto(`${baseURL}?${queryString.stringify(rest)}`);

        // Removing the storybook decorations (left & right panel).
        await page.keyboard.down('Shift');
        await page.keyboard.down('Control');
        await page.keyboard.press('KeyX');
        await page.keyboard.press('KeyC');
        await page.keyboard.up('Control');
        await page.keyboard.up('Shift');

        console.log('Snapshotting Story - ', rest);
        await snapshot(page, rest);

        if (steps) {
          console.log('Snapshotting Steps - ', steps);
          const frame = page.frames().find(frame => frame.name() === 'storybook-preview-iframe');

          for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
            const step = steps[stepIndex];
            await actions[step.type]({ story: rest, data: step.data, page, frame });
          }
        }
      }
    })
  );

  console.log('Finished running visual regression tests');
  await browser.close();
})();

I also have a small wrapper for storybook that saves all of the stories on the window on the variable __SB-stories__ (in some cases I also add the steps that interact with this specific story)

Sorry to dump all of this like that without a more succinct repro, but I don't really have the time to reduce it further 😞

joshvillahermosa commented 5 years ago

Hi, I'm starting to see this as well when I try to run percySnapshot async. I am also running this on different pages as well. Is this recommended practices or so?

djones commented 5 years ago

This issue should be resolved now with https://github.com/percy/percy-agent/pull/168 merged. Please upgrade to @percy/agent version 0.5.0 or higher.