@applitools/eyes-storybook
Version:
471 lines (425 loc) • 15.5 kB
JavaScript
'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;