UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

372 lines 16.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.InternalBrowser = void 0; const path_1 = __importDefault(require("path")); const assert_1 = __importDefault(require("assert")); const playwright_core_1 = require("playwright-core"); const chalk_1 = __importDefault(require("chalk")); const uuid_1 = require("uuid"); const loglevel_1 = __importDefault(require("loglevel")); const loglevel_plugin_prefix_1 = __importDefault(require("loglevel-plugin-prefix")); const types_1 = require("../../types"); const webdriver_1 = require("../webdriver"); const utils_1 = require("../utils"); const logger_1 = require("../logger"); const context_1 = require("../worker/context"); const storybook_helpers_js_1 = require("../storybook-helpers.js"); const browsers = { chromium: playwright_core_1.chromium, firefox: playwright_core_1.firefox, webkit: playwright_core_1.webkit, }; async function tryConnect(type, gridUrl, timeoutMs) { let timeout = null; let isTimeout = false; let error = null; return Promise.race([ new Promise((resolve) => (timeout = setTimeout(() => { isTimeout = true; (0, logger_1.logger)().error(`Can't connect to ${type.name()} playwright browser`, error); resolve(null); }, timeoutMs))), (async () => { let browser = null; do { try { browser = await type.connect(gridUrl); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (timeout) clearTimeout(timeout); break; } catch (e) { error = e; } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } while (!isTimeout); return browser; })(), ]); } async function tryCreateBrowserContext(browser, options) { try { const context = await browser.newContext(options); const page = await context.newPage(); return { context, page }; } catch (error) { if (error instanceof Error && error.message.includes('ffmpeg')) { (0, logger_1.logger)().warn('Failed to create browser context with video recording. Video recording will be disabled.'); (0, logger_1.logger)().warn(error); const context = await browser.newContext({ ...options, recordVideo: undefined, }); const page = await context.newPage(); return { context, page }; } throw error; } } class InternalBrowser { #isShuttingDown = false; #browser; #context; #page; #traceDir; #sessionId = (0, uuid_1.v4)(); #debug; #storybookGlobals; #shouldReinit = false; constructor(browser, context, page, traceDir, debug = false, storybookGlobals) { this.#browser = browser; this.#context = context; this.#page = page; this.#traceDir = traceDir; this.#debug = debug; this.#storybookGlobals = storybookGlobals; } // TODO Expose #browser and #context in tests get browser() { return this.#page; } get sessionId() { return this.#sessionId; } async closeBrowser() { if (this.#isShuttingDown) return; this.#isShuttingDown = true; const teardown = [ this.#debug ? () => this.#context.tracing.stop({ path: path_1.default.join(this.#traceDir, 'trace.zip') }) : null, () => this.#page.close(), this.#debug ? () => this.#page.video()?.saveAs(path_1.default.join(this.#traceDir, 'video.webm')) : null, () => this.#context.close(), () => this.#browser.close(), () => (0, context_1.removeWorkerContainer)(), ]; for (const fn of teardown) { try { if (fn) await fn(); } catch { /* noop */ } } } async takeScreenshot(captureElement, ignoreElements, options) { const ignore = Array.isArray(ignoreElements) ? ignoreElements : ignoreElements ? [ignoreElements] : []; const mask = ignore.map((el) => this.#page.locator(el)); if (captureElement) { const element = await this.#page.$(captureElement); if (!element) throw new Error(`Element with selector ${captureElement} not found`); (0, logger_1.logger)().debug(`Capturing ${chalk_1.default.cyan(captureElement)} element`); return element.screenshot({ style: ':root { overflow: hidden !important; }', animations: 'disabled', mask, ...options, }); } (0, logger_1.logger)().debug('Capturing viewport screenshot'); return this.#page.screenshot({ animations: 'disabled', mask, ...options }); } async selectStory(id) { if (this.#shouldReinit) { this.#shouldReinit = false; const done = await this.initStorybook(); if (!done) return; } await this.#page.evaluate(() => delete window.__CREEVEY_SELECT_STORY_RESULT__); await this.resetMousePosition(); (0, logger_1.logger)().debug(`Triggering 'SetCurrentStory' event with storyId ${chalk_1.default.magenta(id)}`); const reloadWatcher = this.#page.waitForFunction((id) => id !== window.__CREEVEY_SESSION_ID__, this.#sessionId); const selectWatcher = this.#page.waitForFunction(() => window.__CREEVEY_SELECT_STORY_RESULT__); void this.#page.evaluate(storybook_helpers_js_1.selectStory, id); await Promise.race([reloadWatcher, selectWatcher]); let result = null; try { result = await this.#page.evaluate(() => window.__CREEVEY_SELECT_STORY_RESULT__); } catch (error) { // TODO: Debug why select watcher resolved, but we still fail with execution context destroyed // Maybe we need to wait for page to be fully loaded??? if (error instanceof Error && error.message.includes('Execution context was destroyed')) { // Ignore error } else { throw error; } } if (!result) { (0, logger_1.logger)().debug('Storybook page has been reloaded during story selection'); const done = await this.initStorybook(); if (!done) return; } if (result?.status === 'error') { throw new Error(`Failed to select story: ${result.message}`); } } async updateStoryArgs(story, updatedArgs) { await this.#page.evaluate(([storyId, updatedArgs, UPDATE_STORY_ARGS, STORY_RENDERED]) => { return new Promise((resolve) => { // TODO Check if it's right way to wait for story to be rendered window.__STORYBOOK_ADDONS_CHANNEL__.once(STORY_RENDERED, resolve); window.__STORYBOOK_ADDONS_CHANNEL__.emit(UPDATE_STORY_ARGS, { storyId, updatedArgs, }); }); }, [story.id, updatedArgs, types_1.StorybookEvents.UPDATE_STORY_ARGS, types_1.StorybookEvents.STORY_RENDERED]); } async loadStoriesFromBrowser() { // @ts-expect-error TODO: Fix this return await this.#page.evaluate(storybook_helpers_js_1.getStories); } static async getBrowser(browserName, gridUrl, config, debug) { const browserConfig = config.browsers[browserName]; const { storybookUrl: address = config.storybookUrl, viewport, // eslint-disable-next-line @typescript-eslint/no-deprecated _storybookGlobals, storybookGlobals = _storybookGlobals, seleniumCapabilities, playwrightOptions, connectionTimeout, } = browserConfig; // Use browser-specific timeout, or global config timeout, or default to 60000ms const connectionTimeoutMs = connectionTimeout ?? config.connectionTimeout ?? 60_000; const parsedUrl = new URL(gridUrl); const tracesDir = path_1.default.join(playwrightOptions?.tracesDir ?? path_1.default.join(config.reportDir, 'traces'), process.pid.toString()); const cacheDir = await (0, utils_1.getCreeveyCache)(); (0, assert_1.default)(cacheDir, "Couldn't get cache directory"); let browser = null; if (parsedUrl.protocol === 'ws:') { browser = await tryConnect(browsers[(0, utils_1.resolvePlaywrightBrowserType)(browserConfig.browserName)], gridUrl, connectionTimeoutMs); } else if (parsedUrl.protocol === 'creevey:') { browser = await browsers[(0, utils_1.resolvePlaywrightBrowserType)(browserConfig.browserName)].launch({ ...playwrightOptions, tracesDir: path_1.default.join(cacheDir, `${process.pid}`), }); } else { if (browserConfig.browserName !== 'chrome') { (0, logger_1.logger)().error("Playwright's Selenium Grid feature supports only chrome browser"); return null; } process.env.SELENIUM_REMOTE_URL = gridUrl; process.env.SELENIUM_REMOTE_CAPABILITIES = JSON.stringify(seleniumCapabilities); browser = await playwright_core_1.chromium.launch({ ...playwrightOptions, tracesDir: path_1.default.join(cacheDir, `${process.pid}`) }); } if (!browser) { return null; } const { context, page } = await tryCreateBrowserContext(browser, { recordVideo: debug ? { dir: path_1.default.join(cacheDir, `${process.pid}`), size: viewport, } : undefined, screen: viewport, viewport, }); if (debug) { await context.tracing.start(Object.assign({ screenshots: true, snapshots: true, sources: true }, playwrightOptions?.trace)); } if ((0, logger_1.logger)().getLevel() <= loglevel_1.default.levels.DEBUG) { page.on('console', (msg) => { (0, logger_1.logger)().debug(`Console message: ${msg.text()}`); }); } const internalBrowser = new InternalBrowser(browser, context, page, tracesDir, debug, storybookGlobals); try { if (utils_1.isShuttingDown.current) return null; const done = await internalBrowser.init({ browserName, storybookUrl: address, }); return done ? internalBrowser : null; } catch (originalError) { void internalBrowser.closeBrowser(); const message = originalError instanceof Error ? originalError.message : originalError; const error = new Error(`Can't load storybook root page: ${message}`); if (originalError instanceof Error) error.stack = originalError.stack; (0, logger_1.logger)().error(error); return null; } } async init({ browserName, storybookUrl }) { const sessionId = this.#sessionId; loglevel_plugin_prefix_1.default.apply((0, logger_1.logger)(), { format(level) { const levelColor = logger_1.colors[level.toUpperCase()]; return `[${browserName}:${chalk_1.default.gray(process.pid)}] ${levelColor(level)} => ${chalk_1.default.gray(sessionId)}`; }, }); this.#page.setDefaultTimeout(60000); await this.#page.addInitScript(() => { requestAnimationFrame(check); function check() { if (document.readyState !== 'complete' || typeof window.__STORYBOOK_PREVIEW__ === 'undefined' || typeof window.__STORYBOOK_ADDONS_CHANNEL__ === 'undefined' || (!('ready' in window.__STORYBOOK_PREVIEW__) && window.__STORYBOOK_ADDONS_CHANNEL__.last('setGlobals') === undefined)) { requestAnimationFrame(check); return; } if ('ready' in window.__STORYBOOK_PREVIEW__) { // NOTE: Storybook <= 7.x doesn't have ready() method void window.__STORYBOOK_PREVIEW__.ready().then(() => (window.__CREEVYE_STORYBOOK_READY__ = true)); } else { window.__CREEVYE_STORYBOOK_READY__ = true; } } }); return await (0, utils_1.runSequence)([() => this.openStorybookPage(storybookUrl), () => this.initStorybook()], () => !this.#isShuttingDown); } async initStorybook() { await this.#page.evaluate((id) => (window.__CREEVEY_SESSION_ID__ = id), this.#sessionId); return await (0, utils_1.runSequence)([() => this.waitForStorybook(), () => this.loadStorybookStories(), () => this.defineGlobals()], () => !this.#isShuttingDown); } async openStorybookPage(storybookUrl) { if (!webdriver_1.LOCALHOST_REGEXP.test(storybookUrl)) { await this.#page.goto((0, webdriver_1.appendIframePath)(storybookUrl)); return; } try { const resolvedUrl = await (0, webdriver_1.resolveStorybookUrl)((0, webdriver_1.appendIframePath)(storybookUrl), (url) => this.checkUrl(url)); await this.#page.goto(resolvedUrl); } catch (error) { (0, logger_1.logger)().error('Failed to resolve storybook URL', error instanceof Error ? error.message : ''); throw error; } } async checkUrl(url) { const page = await this.#browser.newPage(); try { (0, logger_1.logger)().debug(`Opening ${chalk_1.default.magenta(url)} and checking the page source`); const response = await page.goto(url, { waitUntil: 'commit' }); const source = await response?.text(); (0, logger_1.logger)().debug(`Checking ${chalk_1.default.cyan(`#${webdriver_1.storybookRootID}`)} existence on ${chalk_1.default.magenta(url)}`); return source?.includes(`id="${webdriver_1.storybookRootID}"`) ?? false; } catch { return false; } finally { await page.close(); } } async waitForStorybook() { (0, logger_1.logger)().debug('Waiting for Storybook to initiate'); await this.#page.waitForFunction(() => window.__CREEVYE_STORYBOOK_READY__); } async loadStorybookStories() { (0, logger_1.logger)().debug('Loading Storybook stories'); const storiesWatcher = this.#page.waitForFunction(() => window.__CREEVEY_STORYBOOK_STORIES__); const reloadWatcher = this.#page.waitForFunction((id) => id !== window.__CREEVEY_SESSION_ID__, this.#sessionId); void this.#page.evaluate(() => { void window.__STORYBOOK_PREVIEW__.extract().then((stories) => { window.__CREEVEY_STORYBOOK_STORIES__ = stories; }); }); const type = await Promise.race([storiesWatcher.then(() => 'stories'), reloadWatcher.then(() => 'reload')]); if (type === 'reload') { (0, logger_1.logger)().debug('Storybook page reloaded'); await this.waitForStorybook(); } } async resetMousePosition() { (0, logger_1.logger)().debug('Resetting mouse position to (0, 0)'); await this.#page.mouse.move(0, 0); } async defineGlobals() { (0, logger_1.logger)().debug('Defining Storybook globals'); const globalsWatcher = this.#page.waitForFunction(() => window.__CREEVEY_STORYBOOK_GLOBALS__); void this.#page.evaluate((userGlobals) => { // @ts-expect-error https://github.com/evanw/esbuild/issues/2605#issuecomment-2050808084 window.__name = (func) => func; if (userGlobals) { window.__STORYBOOK_ADDONS_CHANNEL__.once('globalsUpdated', ({ globals }) => { window.__CREEVEY_STORYBOOK_GLOBALS__ = globals; }); window.__STORYBOOK_ADDONS_CHANNEL__.emit('updateGlobals', { globals: userGlobals }); } else { window.__CREEVEY_STORYBOOK_GLOBALS__ = {}; } }, this.#storybookGlobals); await globalsWatcher; } } exports.InternalBrowser = InternalBrowser; //# sourceMappingURL=internal.js.map