UNPKG

@applitools/eyes-storybook

Version:
471 lines (425 loc) 15.5 kB
'use strict'; const puppeteer = require('puppeteer'); const configDigest = require('./configDigest'); const getStories = require('../dist/getStories'); const {presult} = require('@applitools/functional-commons'); const {executeWithRetry} = require('./utils/executeWithRetry'); const chalk = require('chalk'); const makeInitPage = require('./initPage'); const makeRenderStory = require('./renderStory'); const makeRenderStories = require('./renderStories'); const makeGetStoryData = require('./getStoryData'); const ora = require('ora'); const filterStories = require('./filterStories'); const addVariationStories = require('./addVariationStories'); const browserLog = require('./browserLog'); const getIframeUrl = require('./getIframeUrl'); const createPagePool = require('./pagePool'); const getClientAPI = require('../dist/getClientAPI'); const {mergeConfigs} = require('./utils/merge-configs'); const {Driver} = require('@applitools/driver'); const spec = require('@applitools/spec-driver-puppeteer'); const {refineErrorMessage} = require('./errMessages'); const executeRenders = require('./executeRenders'); const {extractEnvironment} = require('./extractEnvironment'); const {makeCore} = require('@applitools/core'); const makeGetStoriesWithConfig = require('./getStoriesWithConfig'); const {makeNetworkUtils} = require('./utils/pageNetworkUtils'); const {readStoriesTimeout: defaultReadStoriesTimeout} = require('./defaultConfig'); async function eyesStorybook({ config, logger, performance, timeItAsync, outputStream = process.stderr, eventEmitter, signal = new AbortController().signal, addonVersion, }) { logger.log(`Running with the following config:\n${configDigest(config)}`); let renderIE = false; let transitioning = false; logger.log('eyesStorybook started'); const CONCURRENT_TABS = isNaN(Number(process.env.APPLITOOLS_CONCURRENT_TABS)) ? 3 : Number(process.env.APPLITOOLS_CONCURRENT_TABS); logger.log(`Running with ${CONCURRENT_TABS} concurrent tabs`); const {storybookUrl, readStoriesTimeout, reloadPagePerStory, navigationWaitUntil} = config; const enableTimeoutRetryMechanism = readStoriesTimeout === defaultReadStoriesTimeout; const timeoutRetry = 30_000; let iframeUrl; try { iframeUrl = getIframeUrl(storybookUrl); logger.log('iframeUrl:', iframeUrl); } catch (ex) { logger.log(ex); throw new Error(`Storybook URL is not valid: ${storybookUrl}`); } process.env.PUPPETEER_DISABLE_HEADLESS_WARNING = true; const browser = await puppeteer.launch(config.puppeteerOptions); logger.log('browser launched'); const environment = extractEnvironment(addonVersion); const core = await makeCore({spec, agentId: config.agentId, environment, logger}); const manager = await core.makeManager({ type: 'ufg', settings: {concurrency: config.testConcurrency, useServerConcurrency: true}, }); const account = await core .getAccountInfo({ settings: { eyesServerUrl: config.eyesServerUrl, apiKey: config.apiKey, agentId: config.agentId, proxy: config.proxy, useDnsCache: config.useDnsCache, }, }) .catch(async error => { logger.error(error?.message); await browser.close(); throw error; }); const getStoriesWithConfig = makeGetStoriesWithConfig({config}); const initPage = makeInitPage({ iframeUrl, config, browser, logger, getTransitiongIntoIE, getRenderIE, }); const pagePool = createPagePool({initPage, logger}); try { const {stories, page} = await getStoriesWithSpinner(); const doTakeDomSnapshots = async ({page, ...settings}) => { const driver = await new Driver({spec, driver: page, logger}); return await core.takeSnapshots({ logger, driver, settings: mergeConfigs({config, settings}), account, }); }; logger.log('got script for processPage'); browserLog({ page, onLog: text => { logger.log(`master tab: ${text}`); }, }); const filteredStories = filterStories({stories, config}); // Log filtering and sharding results logger.log(`${stories.length} total stories found`); if (config.shard) { logger.log( `${filteredStories.length} stories after filtering and sharding (shard ${config.shard.current}/${config.shard.total})`, ); } else { logger.log(`${filteredStories.length} stories after filtering`); } const storiesIncludingVariations = addVariationStories({ stories: filteredStories, config, }); logger.log( `there are ${storiesIncludingVariations.length} stories after filtering and adding variations `, ); const storiesByBrowserWithConfig = getStoriesWithConfig({ stories: storiesIncludingVariations, logger, }); logger.log( `starting to run ${storiesByBrowserWithConfig.stories.length} normal stories ("non fake IE") and ${storiesByBrowserWithConfig.storiesWithIE.length} "fake IE stories"`, ); const getStoryData = makeGetStoryData({ logger, takeDomSnapshots: doTakeDomSnapshots, reloadPagePerStory, navigationWaitUntil, }); const renderStory = makeRenderStory({ logger: logger.extend({label: 'renderStory'}), openEyes: manager.openEyes, performance, timeItAsync, storyDataGap: config.storyDataGap, concurrency: account.serverConcurrency.componentConcurrency, appName: config.appName, serverSettings: account.eyesServer, signal, }); const renderStories = makeRenderStories({ getStoryData, renderStory, sanityCheckForPage, storybookUrl, logger, stream: outputStream, pagePool, eventEmitter, signal, }); logger.log('finished creating functions'); await initializeTabs(); const [error, results] = await presult( executeRenders({ renderStories, setRenderIE, setTransitioningIntoIE, storiesByBrowserWithConfig, pagePool, logger, timeItAsync, }), ); if (signal.aborted) { if (error) { const msg = refineErrorMessage({prefix: 'Error in executeRenders:', error}); logger.log('Error in executeRenders:', error); throw new Error(msg); } else { return {results}; // processResults (which is not used in the addon) doesn't support missing the summary. But it's not a real concern right now. } } const [errorInGetResults, testResultsSummary] = await presult( manager.getResults({throwErr: false}), ); if (errorInGetResults) { logger.log('failed to get results', errorInGetResults); } if (error) { const msg = refineErrorMessage({prefix: 'Error in executeRenders:', error}); logger.log('Error in executeRenders:', error); throw new Error(msg); } else { return {summary: testResultsSummary, results}; } } finally { logger.log('total time: ', performance['renderStories']); logger.log('perf results', performance); pagePool.isClosed = true; await browser.close(); } async function createContext({browser, config, logger}) { const context = await browser.createBrowserContext(); const page = await context.newPage(); logger.log('A new context and page created'); // Set up interception and headers, but do not navigate const {startInterception} = makeNetworkUtils({page, logger}); if (config.puppeteerExtraHTTPHeaders) { await page.setExtraHTTPHeaders(config.puppeteerExtraHTTPHeaders); } await startInterception({ timeout: config.browserRequestsTimeout, blockPatterns: config.networkBlockPatterns, browserHeadersOverride: config.browserHeadersOverride, cache: config.browserCacheRequests, }); return {context, page}; } async function getStoriesWithSpinner() { let firstAttemptContext; let secondAttemptContext; let secondAttemptTimer; let winnerFound = false; async function attemptNavigate(timeout, waitUntil, isSecondAttempt = false) { // If a winner was already found, avoid creating a new context for the second attempt if (isSecondAttempt && winnerFound) return {ok: false}; const contextObj = await createContext({browser, config, logger}); if (isSecondAttempt) secondAttemptContext = contextObj; else firstAttemptContext = contextObj; const page = contextObj.page; logger.log( 'Attempting navigation to storybook url:', storybookUrl, 'timeout:', timeout, 'waitUntil:', waitUntil, ); const [navigateErr] = await presult(page.goto(storybookUrl, {timeout, waitUntil})); if (navigateErr) { logger.log('Error when loading storybook', navigateErr); return {ok: false, error: navigateErr, page}; } return {ok: true, page}; } const spinner = ora({text: 'Reading stories', stream: outputStream}); spinner.start(); // Start first attempt immediately with original timeout/waitUntil const firstAttempt = attemptNavigate(readStoriesTimeout, navigationWaitUntil); logger.log('Started first navigation attempt'); // Start second attempt after 30 seconds with 2x timeout const secondAttempt = enableTimeoutRetryMechanism ? new Promise(resolve => { secondAttemptTimer = setTimeout(() => { attemptNavigate(readStoriesTimeout * 2, navigationWaitUntil, true).then(resolve); }, timeoutRetry); }) : Promise.resolve({ok: false}); // Helper for reading stories after successful navigation async function handleReadStories(page) { let hasConsoleErr; page.on('console', msg => { hasConsoleErr = msg.args()[0] && msg.args()[0]._remoteObject && msg.args()[0]._remoteObject.subtype === 'error'; }); logger.log('Getting stories from storybook'); const [getStoriesErr, stories] = await presult(readStoriesWithRetry(page)); if (getStoriesErr) { logger.log('Error when reading stories:', getStoriesErr); const failMsg = refineErrorMessage({ prefix: 'Error when reading stories:', error: getStoriesErr, }); spinner.fail(failMsg); throw new Error(); } if (!stories.length && hasConsoleErr) { throw new Error( 'Could not load stories, make sure your storybook renders correctly. Perhaps no stories were rendered?', ); } const badParamsError = stories .map(s => s.error) .filter(Boolean) .join('\n'); if (badParamsError) { console.log(chalk.red(`\n${badParamsError}`)); } spinner.succeed(); logger.log(`got ${stories.length} stories:`, JSON.stringify(stories)); return {stories, page}; } async function cleanupContext(winnerPage) { if (secondAttemptTimer) clearTimeout(secondAttemptTimer); if (winnerPage === firstAttemptContext?.page && secondAttemptContext) { try { await secondAttemptContext.page.close(); await secondAttemptContext.context.close(); logger.log('Closed second attempt context/page after first attempt succeeded'); } catch (err) { logger.log('Error closing second attempt context/page:', err); } } else if (winnerPage === secondAttemptContext?.page && firstAttemptContext) { try { await firstAttemptContext.page.close(); await firstAttemptContext.context.close(); logger.log('Closed first attempt context/page after second attempt succeeded'); } catch (err) { logger.log('Error closing first attempt context/page:', err); } } } // Wait for the first successful navigation return Promise.race([firstAttempt, secondAttempt]).then(async result => { if (result.ok) { winnerFound = true; await cleanupContext(result.page); return handleReadStories(result.page); } // If first finished with error, wait for the other return Promise.all([firstAttempt, secondAttempt]).then(async results => { const success = results.find(r => r.ok); if (success) { winnerFound = true; await cleanupContext(success.page); return handleReadStories(success.page); } const lastError = results[1].error || results[0].error; logger.log('Error when loading storybook', lastError); const failMsg = refineErrorMessage({ prefix: 'Error when loading storybook.', error: lastError, }); spinner.fail(failMsg); throw new Error(); }); }); } function getRenderIE() { return renderIE; } function setRenderIE(value) { renderIE = value; } function setTransitioningIntoIE(value) { transitioning = value; } function getTransitiongIntoIE() { return transitioning; } async function readStoriesWithRetry(page) { return executeWithRetry( _readStoriesWithRetry, { timeout: readStoriesTimeout, delayBetweenRetries: 1000, initialErrMessage: `Could not get stories since readStoriesTimeout is too short (${readStoriesTimeout}ms)`, }, readStoriesTimeout, ); async function _readStoriesWithRetry() { try { const stories = await page.evaluate(getStories); if (stories.length > 0) { return stories; } else { throw new Error('Got 0 stories'); } } catch (err) { logger.log('Error in _readStoriesWithRetry:', err, ', retrying...'); throw err; } } } async function sanityCheckForPage({page, pageId}) { try { await executeWithRetry(_sanityCheckForPage, { timeout: readStoriesTimeout, delayBetweenRetries: 1000, initialErrMessage: `Could not get client API in sanity check since readStoriesTimeout is too short (${readStoriesTimeout}ms)`, }); } catch (err) { logger.log(`[page ${pageId}] Sanity check failed. Getting page HTML for inspection`); try { const html = await page.content(); logger.log(`[page ${pageId}] Page HTML: ${html}`); } catch (htmlErr) { logger.log(`[page ${pageId}] Error getting page HTML:`, htmlErr); } throw err; } async function _sanityCheckForPage() { try { await page.evaluate(getClientAPI); } catch (err) { logger.log(`[page ${pageId}] Error in getClientAPI during sanity check. Retrying...`, err); throw err; } } } async function initializeTabs() { logger.log('initializing tabs'); try { await Promise.all( Array.from({length: CONCURRENT_TABS}, async () => { const {page, pageId} = await pagePool.createPage(); await sanityCheckForPage({page, pageId}); pagePool.addToPool(pageId); }), ); } catch (initializeTabsError) { const msg = refineErrorMessage({ prefix: 'Error initializing browser tabs with storybook:', error: initializeTabsError, }); logger.log(msg); ora({text: msg, stream: outputStream}).fail(); throw new Error(); } } } module.exports = eyesStorybook;