diff --git a/.engine_scripts/auto-scroll.js b/.engine_scripts/auto-scroll.ts similarity index 83% rename from .engine_scripts/auto-scroll.js rename to .engine_scripts/auto-scroll.ts index 6bbeee2..36dc16c 100644 --- a/.engine_scripts/auto-scroll.js +++ b/.engine_scripts/auto-scroll.ts @@ -1,4 +1,4 @@ -module.exports = async () => { +export default async (): Promise => { const SCROLL_DISTANCE = 100; const SCROLL_DOWN_MAX = 100; const SCROLL_TOP_MAX = 100; @@ -7,11 +7,11 @@ module.exports = async () => { return; } - const scrollToBottom = async () => { + const scrollToBottom = async (): Promise => { window.visualTestScrollingBottom = true; let counter = 0; let lastScrollY = 0; - await new Promise((resolve) => { + await new Promise((resolve) => { const timer = setInterval(() => { if (window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight || counter > SCROLL_DOWN_MAX) { // you're at the bottom of the page @@ -33,8 +33,8 @@ module.exports = async () => { }); }; - const scrollToTop = async () => { - await new Promise((resolve) => { + const scrollToTop = async (): Promise => { + await new Promise((resolve) => { const timer = setInterval(() => { if (!window.visualTestScrollingBottom) { clearInterval(timer); @@ -43,7 +43,7 @@ module.exports = async () => { } }, 100); }); - await new Promise((resolve) => { + await new Promise((resolve) => { let counter = 0; const timer = setInterval(() => { if (window.scrollY === 0 || counter > SCROLL_TOP_MAX) { diff --git a/.engine_scripts/engine.d.ts b/.engine_scripts/engine.d.ts new file mode 100644 index 0000000..85b63d7 --- /dev/null +++ b/.engine_scripts/engine.d.ts @@ -0,0 +1,101 @@ +import type { Page, Frame, BrowserContext } from 'playwright'; +import type { Page as PuppeteerPage } from 'puppeteer'; +import type { Scenario, Viewport } from 'backstopjs'; + +export interface KeyPressSelector { + selector: string; + keyPress: string; +} + +export interface ScenarioAction { + frame?: string | string[]; + check?: string; + click?: string; + focus?: string; + goto?: string; + hide?: string; + hover?: string; + input?: string; + value?: string; + file?: string | string[]; + append?: boolean; + useFileChooser?: boolean; + remove?: string; + press?: string; + key?: string; + scroll?: string; + select?: string; + label?: string; + uncheck?: string; + wait?: number | string; + url?: string; + persist?: string; + path?: string; +} + +export interface EngineScenario extends Scenario { + index: string; + total: number; + label: string; + url: string; + bypassCsp?: boolean; + cookiePath?: string; + useCssOverride?: boolean; + cssOverridePath?: string; + jsOnReadyPath?: string; + noScrollTop?: boolean; + postInteractionWait?: number | string; + hoverSelector?: string | string[]; + hoverSelectors?: string | string[]; + clickSelector?: string | string[]; + clickSelectors?: string | string[]; + keyPressSelector?: KeyPressSelector | KeyPressSelector[]; + keyPressSelectors?: KeyPressSelector | KeyPressSelector[]; + scrollToSelector?: string; + actions?: ScenarioAction[]; + getTestUrl?: (url: string) => string; +} + +export interface CookieInput { + name: string; + value: string; + domain: string | string[]; + path?: string; + expirationDate?: number; + [k: string]: unknown; +} + +export interface ActionsContext { + currentPage: Page; + scenario: EngineScenario; + browserContext: BrowserContext; +} + +export type BrowserScript = () => Promise; + +export type OnBeforeScript = ( + page: Page, + scenario: EngineScenario, + viewport: Viewport, + isReference: boolean, + browserContext: BrowserContext +) => Promise; +export type OnReadyScript = OnBeforeScript; + +export type EmbedFiles = (scenario: EngineScenario, page: Page) => Promise; +export type OverrideCSS = (page: Page, scenario: EngineScenario) => Promise; +export type LoadCookies = (browserContext: BrowserContext, scenario: EngineScenario) => Promise; +export type Actions = (context: ActionsContext) => Promise; +export type ClickAndHoverHelper = (page: Page | Frame, scenario: EngineScenario) => Promise; + +export type PuppetOnBeforeScript = ( + page: PuppeteerPage, + scenario: EngineScenario, + viewport: Viewport, + isReference: boolean, + browserContext: unknown +) => Promise; +export type PuppetOnReadyScript = (page: PuppeteerPage, scenario: EngineScenario, vp: Viewport) => Promise; +export type PuppetOverrideCSS = (page: PuppeteerPage, scenario: EngineScenario) => Promise; +export type PuppetClickAndHoverHelper = (page: PuppeteerPage, scenario: EngineScenario) => Promise; +export type PuppetLoadCookies = (page: PuppeteerPage, scenario: EngineScenario) => Promise; diff --git a/.engine_scripts/engine.globals.d.ts b/.engine_scripts/engine.globals.d.ts new file mode 100644 index 0000000..ab12128 --- /dev/null +++ b/.engine_scripts/engine.globals.d.ts @@ -0,0 +1,8 @@ +export {}; + +declare global { + interface Window { + visualTestScrollingBottom?: boolean; + _styleData?: string; + } +} diff --git a/.engine_scripts/playwright/actions.js b/.engine_scripts/playwright/actions.ts similarity index 75% rename from .engine_scripts/playwright/actions.js rename to .engine_scripts/playwright/actions.ts index 186e9d7..11ab865 100644 --- a/.engine_scripts/playwright/actions.js +++ b/.engine_scripts/playwright/actions.ts @@ -1,9 +1,11 @@ -const fs = require('fs'); -const path = require('path'); -const YAML = require('js-yaml'); +import * as fs from 'fs'; +import * as path from 'path'; +import type { Page, Frame, ElementHandle } from 'playwright'; +import type { ActionsContext, ScenarioAction, Actions } from '../engine.js'; + const chalkImport = import('chalk').then((m) => m.default); -module.exports = async (context) => { +export default (async (context: ActionsContext): Promise => { const { currentPage, scenario, browserContext } = context; if (!scenario.actions) { @@ -14,8 +16,8 @@ module.exports = async (context) => { const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); for (let i = 0; i < scenario.actions.length; i++) { - let page = currentPage; - let action = scenario.actions[i]; + let page: Page | Frame = currentPage; + let action: ScenarioAction = scenario.actions[i]; if (!action) { continue; @@ -25,8 +27,15 @@ module.exports = async (context) => { const frames = typeof action.frame === 'string' ? [action.frame] : action.frame; for (let j = 0; j < frames.length; j++) { await page.waitForSelector(frames[j]); - const handle = await page.locator(frames[j]).elementHandle(); - page = await handle.contentFrame(); + const handle = (await page.locator(frames[j]).elementHandle()) as ElementHandle; + if (handle === null) { + throw new Error(`iframe element not found for selector: ${frames[j]}`); + } + const frame = await handle.contentFrame(); + if (frame === null) { + throw new Error(`contentFrame missing for iframe element matching selector: ${frames[j]}`); + } + page = frame; } } @@ -79,7 +88,7 @@ module.exports = async (context) => { let el = await page.locator(action.input); if (!action.append) { - await el.evaluate((node) => (node.value = '')); + await el.evaluate((node) => ((node as HTMLInputElement).value = '')); } await el.type(action.value); @@ -89,7 +98,7 @@ module.exports = async (context) => { let el = await page.locator(action.input); const files = typeof action.file === 'string' ? [action.file] : action.file; - let normalizedPaths = []; + let normalizedPaths: string[] = []; files.forEach((file) => { if (path.isAbsolute(file)) { @@ -105,8 +114,8 @@ module.exports = async (context) => { }); if (action.useFileChooser) { - const fileChooserPromise = page.waitForEvent('filechooser'); - el.click(); + const fileChooserPromise = currentPage.waitForEvent('filechooser'); + await el.click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(normalizedPaths); } else { @@ -118,21 +127,21 @@ module.exports = async (context) => { if (!!action.remove) { console.log(logPrefix + 'Remove:', action.remove); await page.waitForSelector(action.remove); - let el = await page.locator(action.hide); + let el = await page.locator(action.remove); await el.evaluate((node) => node.style.setProperty('display', 'none', 'important')); } if (!!action.press) { console.log(logPrefix + 'Press:', action.press); await page.waitForSelector(action.press); - await page.locator(action.press).press(action.key); + await page.locator(action.press).press(action.key!); } if (!!action.scroll) { console.log(logPrefix + 'Scroll:', action.scroll); await page.waitForSelector(action.scroll); - await page.evaluate((scrollToSelector) => { - document.querySelector(scrollToSelector).scrollIntoView(); + await page.evaluate((scrollToSelector: string) => { + document.querySelector(scrollToSelector)!.scrollIntoView(); }, action.scroll); } @@ -169,13 +178,14 @@ module.exports = async (context) => { if (!!scenario.getTestUrl) { url = scenario.getTestUrl(url); } - await page.waitForURL(url); + await (page as Page).waitForURL(url); } - if (parseInt(action.wait) > 0) { - await page.waitForTimeout(action.wait); + const waitTimeMs = parseInt(String(action.wait)); + if (!Number.isNaN(waitTimeMs) && waitTimeMs >= 0) { + await page.waitForTimeout(waitTimeMs); } else { - await page.waitForSelector(action.wait); + await page.waitForSelector(action.wait as string); } } @@ -184,4 +194,4 @@ module.exports = async (context) => { await browserContext.storageState({ path: action.path }); } } -}; +}) as Actions; diff --git a/.engine_scripts/playwright/clickAndHoverHelper.js b/.engine_scripts/playwright/clickAndHoverHelper.ts similarity index 52% rename from .engine_scripts/playwright/clickAndHoverHelper.js rename to .engine_scripts/playwright/clickAndHoverHelper.ts index 8a0e8f0..b8d0af8 100644 --- a/.engine_scripts/playwright/clickAndHoverHelper.js +++ b/.engine_scripts/playwright/clickAndHoverHelper.ts @@ -1,4 +1,7 @@ -module.exports = async (page, scenario) => { +import type { Page, Frame } from 'playwright'; +import type { EngineScenario, KeyPressSelector, ClickAndHoverHelper } from '../engine.js'; + +export default (async (page: Page | Frame, scenario: EngineScenario): Promise => { const hoverSelector = scenario.hoverSelectors || scenario.hoverSelector; const clickSelector = scenario.clickSelectors || scenario.clickSelector; const keyPressSelector = scenario.keyPressSelectors || scenario.keyPressSelector; @@ -6,38 +9,39 @@ module.exports = async (page, scenario) => { const postInteractionWait = scenario.postInteractionWait; // selector [str] | ms [int] if (keyPressSelector) { - for (const keyPressSelectorItem of [].concat(keyPressSelector)) { + for (const keyPressSelectorItem of ([] as KeyPressSelector[]).concat(keyPressSelector as KeyPressSelector[])) { await page.waitForSelector(keyPressSelectorItem.selector); await page.type(keyPressSelectorItem.selector, keyPressSelectorItem.keyPress); } } if (hoverSelector) { - for (const hoverSelectorIndex of [].concat(hoverSelector)) { + for (const hoverSelectorIndex of ([] as string[]).concat(hoverSelector as string[])) { await page.waitForSelector(hoverSelectorIndex); await page.hover(hoverSelectorIndex); } } if (clickSelector) { - for (const clickSelectorIndex of [].concat(clickSelector)) { + for (const clickSelectorIndex of ([] as string[]).concat(clickSelector as string[])) { await page.waitForSelector(clickSelectorIndex); await page.click(clickSelectorIndex); } } if (postInteractionWait) { - if (parseInt(postInteractionWait) > 0) { - await page.waitForTimeout(postInteractionWait); + const interactionWait = parseInt(String(postInteractionWait)); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await page.waitForTimeout(interactionWait); } else { - await page.waitForSelector(postInteractionWait); + await page.waitForSelector(postInteractionWait as string); } } if (scrollToSelector) { await page.waitForSelector(scrollToSelector); - await page.evaluate(scrollToSelector => { - document.querySelector(scrollToSelector).scrollIntoView(); + await page.evaluate((scrollToSel: string) => { + document.querySelector(scrollToSel)!.scrollIntoView(); }, scrollToSelector); } -}; +}) as ClickAndHoverHelper; diff --git a/.engine_scripts/playwright/embedFiles.js b/.engine_scripts/playwright/embedFiles.ts similarity index 59% rename from .engine_scripts/playwright/embedFiles.js rename to .engine_scripts/playwright/embedFiles.ts index 78ee532..9c00538 100644 --- a/.engine_scripts/playwright/embedFiles.js +++ b/.engine_scripts/playwright/embedFiles.ts @@ -1,13 +1,16 @@ -const fs = require('fs'); +import * as fs from 'fs'; +import type { Page } from 'playwright'; +import type { EngineScenario, EmbedFiles, OverrideCSS } from '../engine.js'; + const chalkImport = import('chalk').then((m) => m.default); -const embedCss = async (scenario, page) => { +const embedCss = async (scenario: EngineScenario, page: Page): Promise => { if (scenario.useCssOverride) { - await require('./overrideCSS')(page, scenario); + await (require('./overrideCSS') as OverrideCSS)(page, scenario); } }; -const embedJs = async (scenario, page) => { +const embedJs = async (scenario: EngineScenario, page: Page): Promise => { const jsOnReadyPath = scenario.jsOnReadyPath; const chalk = await chalkImport; const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); @@ -22,7 +25,7 @@ const embedJs = async (scenario, page) => { } }; -module.exports = async (scenario, page) => { +export default (async (scenario: EngineScenario, page: Page): Promise => { await embedJs(scenario, page); await embedCss(scenario, page); -}; +}) as EmbedFiles; diff --git a/.engine_scripts/playwright/interceptImages.js b/.engine_scripts/playwright/interceptImages.ts similarity index 63% rename from .engine_scripts/playwright/interceptImages.js rename to .engine_scripts/playwright/interceptImages.ts index 4077e76..65ff22c 100644 --- a/.engine_scripts/playwright/interceptImages.js +++ b/.engine_scripts/playwright/interceptImages.ts @@ -12,20 +12,22 @@ * */ -const fs = require('fs'); -const path = require('path'); +import * as fs from 'fs'; +import * as path from 'path'; +import type { Page } from 'playwright'; +import type { EngineScenario } from '../engine.js'; const IMAGE_URL_RE = /\.gif|\.jpg|\.png/i; const IMAGE_STUB_URL = path.resolve(__dirname, '../../imageStub.jpg'); const IMAGE_DATA_BUFFER = fs.readFileSync(IMAGE_STUB_URL); const HEADERS_STUB = {}; -module.exports = async function (page, scenario) { - page.route(IMAGE_URL_RE, route => { - route.fulfill({ +export default async function (page: Page, scenario: EngineScenario): Promise { + await page.route(IMAGE_URL_RE, async (route) => { + await route.fulfill({ body: IMAGE_DATA_BUFFER, headers: HEADERS_STUB, - status: 200 + status: 200, }); }); -}; +} diff --git a/.engine_scripts/playwright/loadCookies.js b/.engine_scripts/playwright/loadCookies.ts similarity index 61% rename from .engine_scripts/playwright/loadCookies.js rename to .engine_scripts/playwright/loadCookies.ts index 5bd1755..755185e 100644 --- a/.engine_scripts/playwright/loadCookies.js +++ b/.engine_scripts/playwright/loadCookies.ts @@ -1,9 +1,12 @@ -const fs = require('fs'); -const YAML = require('js-yaml'); +import * as fs from 'fs'; +import * as YAML from 'js-yaml'; +import type { BrowserContext } from 'playwright'; +import type { CookieInput, EngineScenario, LoadCookies } from '../engine.js'; + const chalkImport = import('chalk').then((m) => m.default); -module.exports = async (browserContext, scenario) => { - let cookiesFromFile = []; +export default (async (browserContext: BrowserContext, scenario: EngineScenario): Promise => { + let cookiesFromFile: CookieInput[] = []; const cookiePath = scenario.cookiePath; const chalk = await chalkImport; const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); @@ -12,19 +15,19 @@ module.exports = async (browserContext, scenario) => { if (!!cookiePath && fs.existsSync(cookiePath)) { let content = fs.readFileSync(cookiePath); if (cookiePath.endsWith('.json')) { - cookiesFromFile = JSON.parse(content); + cookiesFromFile = JSON.parse(content.toString()); } else if (cookiePath.endsWith('.yaml') || cookiePath.endsWith('.yml')) { - cookiesFromFile = YAML.load(content); + cookiesFromFile = YAML.load(content.toString()) as CookieInput[]; } } - const cookies = []; + const cookies: CookieInput[] = []; // MUNGE COOKIE DOMAIN - [].forEach.call(cookiesFromFile, (c) => { + [].forEach.call(cookiesFromFile, (c: CookieInput) => { let domains = typeof c.domain === 'string' ? [c.domain] : c.domain; - [].forEach.call(domains, (domain) => { + [].forEach.call(domains, (domain: string) => { const cookie = { ...c, domain }; if (!cookie.expirationDate) { @@ -41,8 +44,8 @@ module.exports = async (browserContext, scenario) => { } // Add cookies to browser - await browserContext.addCookies(cookies); + await browserContext.addCookies(cookies as Parameters[0]); // console.log('Cookie state restored with:', JSON.stringify(cookies, null, 2)); console.log(logPrefix + 'Cookie state restored for: ' + scenario.label); -}; +}) as LoadCookies; diff --git a/.engine_scripts/playwright/onBefore.js b/.engine_scripts/playwright/onBefore.ts similarity index 50% rename from .engine_scripts/playwright/onBefore.js rename to .engine_scripts/playwright/onBefore.ts index 50d1a09..59b6526 100644 --- a/.engine_scripts/playwright/onBefore.js +++ b/.engine_scripts/playwright/onBefore.ts @@ -1,4 +1,14 @@ -module.exports = async (page, scenario, viewport, isReference, browserContext) => { +import type { Page, BrowserContext } from 'playwright'; +import type { Viewport } from 'backstopjs'; +import type { EngineScenario, OnBeforeScript, LoadCookies } from '../engine.js'; + +export default (async ( + page: Page, + scenario: EngineScenario, + viewport: Viewport, + isReference: boolean, + browserContext: BrowserContext +): Promise => { if (scenario.bypassCsp) { const browser = browserContext.browser(); const browserName = browser ? browser.browserType().name() : undefined; @@ -11,5 +21,5 @@ module.exports = async (page, scenario, viewport, isReference, browserContext) = } } - await require('./loadCookies')(browserContext, scenario); -}; + await (require('./loadCookies') as LoadCookies)(browserContext, scenario); +}) as OnBeforeScript; diff --git a/.engine_scripts/playwright/onReady.js b/.engine_scripts/playwright/onReady.js deleted file mode 100644 index c946520..0000000 --- a/.engine_scripts/playwright/onReady.js +++ /dev/null @@ -1,38 +0,0 @@ -const autoScroll = require('../auto-scroll'); -const scrollTop = require('../scroll-top'); -const chalkImport = import('chalk').then((m) => m.default); - -module.exports = async (page, scenario, viewport, isReference, browserContext) => { - await require('./embedFiles')(scenario, page); - await page.evaluate(autoScroll); - const chalk = await chalkImport; - const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); - - page.on('load', async (data) => { - try { - await require('./embedFiles')(scenario, data); - await data.evaluate(require('../auto-scroll')); - } catch (error) { - console.log(logPrefix + error); - } - }); - - console.log(logPrefix + 'SCENARIO > ' + scenario.label); - - if (!!scenario.actions) { - await require('./actions')({ currentPage: page, scenario, browserContext }); - } else { - await require('./clickAndHoverHelper')(page, scenario); - } - - if (!scenario.noScrollTop) { - await page.evaluate(scrollTop); - } - - // add more ready handlers here... - // await page.waitForLoadState('load', { timeout: 5000 }); - - if (scenario.postInteractionWait) { - await page.waitForTimeout(scenario.postInteractionWait); - } -}; diff --git a/.engine_scripts/playwright/onReady.ts b/.engine_scripts/playwright/onReady.ts new file mode 100644 index 0000000..542f00c --- /dev/null +++ b/.engine_scripts/playwright/onReady.ts @@ -0,0 +1,57 @@ +import type { Page, BrowserContext } from 'playwright'; +import type { Viewport } from 'backstopjs'; +import type { EngineScenario, OnReadyScript, EmbedFiles, Actions, ClickAndHoverHelper, BrowserScript } from '../engine.js'; +import autoScroll from '../auto-scroll.js'; +import scrollTop from '../scroll-top.js'; + +const chalkImport = import('chalk').then((m) => m.default); +const embedFiles = require('./embedFiles') as EmbedFiles; +const autoScrolls = require('../auto-scroll') as BrowserScript; +const actions = require('./actions') as Actions; +const clickAndHoverHelper = require('./clickAndHoverHelper') as ClickAndHoverHelper; + +export default (async ( + page: Page, + scenario: EngineScenario, + viewport: Viewport, + isReference: boolean, + browserContext: BrowserContext +): Promise => { + await embedFiles(scenario, page); + await page.evaluate(autoScroll); + const chalk = await chalkImport; + const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); + + page.on('load', async (data) => { + try { + await embedFiles(scenario, data); + await data.evaluate(autoScrolls); + } catch (error) { + console.log(logPrefix + error); + } + }); + + console.log(logPrefix + 'SCENARIO > ' + scenario.label); + + if (!!scenario.actions) { + await actions({ currentPage: page, scenario, browserContext }); + } else { + await clickAndHoverHelper(page, scenario); + } + + if (!scenario.noScrollTop) { + await page.evaluate(scrollTop); + } + + // add more ready handlers here... + // await page.waitForLoadState('load', { timeout: 5000 }); + + if (scenario.postInteractionWait) { + const interactionWait = parseInt(String(scenario.postInteractionWait)); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await page.waitForTimeout(interactionWait); + } else { + await page.waitForSelector(scenario.postInteractionWait as string); + } + } +}) as OnReadyScript; diff --git a/.engine_scripts/playwright/overrideCSS.js b/.engine_scripts/playwright/overrideCSS.ts similarity index 76% rename from .engine_scripts/playwright/overrideCSS.js rename to .engine_scripts/playwright/overrideCSS.ts index 94fdc30..a290b00 100644 --- a/.engine_scripts/playwright/overrideCSS.js +++ b/.engine_scripts/playwright/overrideCSS.ts @@ -1,4 +1,7 @@ -const fs = require('fs'); +import * as fs from 'fs'; +import type { Page } from 'playwright'; +import type { EngineScenario, OverrideCSS } from '../engine.js'; + const chalkImport = import('chalk').then((m) => m.default); /** @@ -14,10 +17,11 @@ const chalkImport = import('chalk').then((m) => m.default); * */ -module.exports = async (page, scenario) => { +export default (async (page: Page, scenario: EngineScenario): Promise => { const cssOverridePath = scenario.cssOverridePath; const chalk = await chalkImport; const logPrefix = chalk.yellow(`[${scenario.index} of ${scenario.total}] `); + console.log('cssOverridePath', cssOverridePath); if (!cssOverridePath) { return; @@ -35,4 +39,4 @@ module.exports = async (page, scenario) => { console.log(logPrefix + 'BACKSTOP_TEST_CSS_OVERRIDE injected for: ' + scenario.label); // console.log(override); -}; +}) as OverrideCSS; diff --git a/.engine_scripts/puppet/clickAndHoverHelper.js b/.engine_scripts/puppet/clickAndHoverHelper.js deleted file mode 100644 index d848fcf..0000000 --- a/.engine_scripts/puppet/clickAndHoverHelper.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = async (page, scenario) => { - const hoverSelector = scenario.hoverSelectors || scenario.hoverSelector; - const clickSelector = scenario.clickSelectors || scenario.clickSelector; - const keyPressSelector = scenario.keyPressSelectors || scenario.keyPressSelector; - const scrollToSelector = scenario.scrollToSelector; - const postInteractionWait = scenario.postInteractionWait; // selector [str] | ms [int] - - if (keyPressSelector) { - for (const keyPressSelectorItem of [].concat(keyPressSelector)) { - await page.waitForSelector(keyPressSelectorItem.selector); - await page.type(keyPressSelectorItem.selector, keyPressSelectorItem.keyPress); - } - } - - if (hoverSelector) { - for (const hoverSelectorIndex of [].concat(hoverSelector)) { - await page.waitForSelector(hoverSelectorIndex); - await page.hover(hoverSelectorIndex); - } - } - - if (clickSelector) { - for (const clickSelectorIndex of [].concat(clickSelector)) { - await page.waitForSelector(clickSelectorIndex); - await page.click(clickSelectorIndex); - } - } - - if (postInteractionWait) { - await page.waitForTimeout(postInteractionWait); - } - - if (scrollToSelector) { - await page.waitForSelector(scrollToSelector); - await page.evaluate(scrollToSelector => { - document.querySelector(scrollToSelector).scrollIntoView(); - }, scrollToSelector); - } -}; diff --git a/.engine_scripts/puppet/clickAndHoverHelper.ts b/.engine_scripts/puppet/clickAndHoverHelper.ts new file mode 100644 index 0000000..e078a5e --- /dev/null +++ b/.engine_scripts/puppet/clickAndHoverHelper.ts @@ -0,0 +1,47 @@ +import type { Page } from 'puppeteer'; +import type { EngineScenario, KeyPressSelector, PuppetClickAndHoverHelper } from '../engine.js'; + +export default (async (page: Page, scenario: EngineScenario): Promise => { + const hoverSelector = scenario.hoverSelectors || scenario.hoverSelector; + const clickSelector = scenario.clickSelectors || scenario.clickSelector; + const keyPressSelector = scenario.keyPressSelectors || scenario.keyPressSelector; + const scrollToSelector = scenario.scrollToSelector; + const postInteractionWait = scenario.postInteractionWait; // selector [str] | ms [int] + + if (keyPressSelector) { + for (const keyPressSelectorItem of ([] as KeyPressSelector[]).concat(keyPressSelector as KeyPressSelector[])) { + await page.waitForSelector(keyPressSelectorItem.selector); + await page.type(keyPressSelectorItem.selector, keyPressSelectorItem.keyPress); + } + } + + if (hoverSelector) { + for (const hoverSelectorIndex of ([] as string[]).concat(hoverSelector as string[])) { + await page.waitForSelector(hoverSelectorIndex); + await page.hover(hoverSelectorIndex); + } + } + + if (clickSelector) { + for (const clickSelectorIndex of ([] as string[]).concat(clickSelector as string[])) { + await page.waitForSelector(clickSelectorIndex); + await page.click(clickSelectorIndex); + } + } + + if (postInteractionWait) { + const interactionWait = parseInt(String(postInteractionWait)); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(interactionWait); + } else { + await page.waitForSelector(postInteractionWait as string); + } + } + + if (scrollToSelector) { + await page.waitForSelector(scrollToSelector); + await page.evaluate((scrollToSel: string) => { + document.querySelector(scrollToSel)!.scrollIntoView(); + }, scrollToSelector); + } +}) as PuppetClickAndHoverHelper; diff --git a/.engine_scripts/puppet/ignoreCSP.js b/.engine_scripts/puppet/ignoreCSP.ts similarity index 64% rename from .engine_scripts/puppet/ignoreCSP.js rename to .engine_scripts/puppet/ignoreCSP.ts index 36851fb..69b6fe1 100644 --- a/.engine_scripts/puppet/ignoreCSP.js +++ b/.engine_scripts/puppet/ignoreCSP.ts @@ -20,14 +20,33 @@ * */ -const fetch = require('node-fetch'); -const https = require('https'); +import * as https from 'https'; +import type { Page } from 'puppeteer'; +import type { EngineScenario } from '../engine.js'; + +// node-fetch is not a declared dependency; typed to reflect the subset used here +type FetchFn = ( + url: string, + options: { + headers: Record; + body: string | undefined; + method: string; + follow: number; + agent: https.Agent; + } +) => Promise<{ + buffer(): Promise; + headers: { _headers: Record }; + status: number; +}>; + +const fetch = require('node-fetch') as FetchFn; const agent = new https.Agent({ rejectUnauthorized: false, }); -module.exports = async function (page, scenario) { - const intercept = async (request, targetUrl) => { +export default async function (page: Page, scenario: EngineScenario): Promise { + const intercept = async (request: Parameters>[1]>[0], targetUrl: string): Promise => { const requestUrl = request.url(); // FIND TARGET URL REQUEST @@ -50,7 +69,7 @@ module.exports = async function (page, scenario) { cleanedHeaders['content-security-policy'] = ''; await request.respond({ body: buffer, - headers: cleanedHeaders, + headers: cleanedHeaders as Record, status: result.status, }); } else { @@ -59,7 +78,7 @@ module.exports = async function (page, scenario) { }; await page.setRequestInterception(true); - page.on('request', (req) => { - intercept(req, scenario.url); + page.on('request', async (req) => { + await intercept(req, scenario.url); }); -}; +} diff --git a/.engine_scripts/puppet/interceptImages.js b/.engine_scripts/puppet/interceptImages.ts similarity index 67% rename from .engine_scripts/puppet/interceptImages.js rename to .engine_scripts/puppet/interceptImages.ts index 2b02be9..c1082a3 100644 --- a/.engine_scripts/puppet/interceptImages.js +++ b/.engine_scripts/puppet/interceptImages.ts @@ -12,21 +12,23 @@ * */ -const fs = require('fs'); -const path = require('path'); +import * as fs from 'fs'; +import * as path from 'path'; +import type { Page } from 'puppeteer'; +import type { EngineScenario } from '../engine.js'; const IMAGE_URL_RE = /\.gif|\.jpg|\.png/i; const IMAGE_STUB_URL = path.resolve(__dirname, '../imageStub.jpg'); const IMAGE_DATA_BUFFER = fs.readFileSync(IMAGE_STUB_URL); const HEADERS_STUB = {}; -module.exports = async function (page, scenario) { - const intercept = async (request, targetUrl) => { +export default async function (page: Page, scenario: EngineScenario): Promise { + const intercept = async (request: Parameters>[1]>[0]): Promise => { if (IMAGE_URL_RE.test(request.url())) { await request.respond({ body: IMAGE_DATA_BUFFER, headers: HEADERS_STUB, - status: 200 + status: 200, }); } else { request.continue(); @@ -34,4 +36,4 @@ module.exports = async function (page, scenario) { }; await page.setRequestInterception(true); page.on('request', intercept); -}; +} diff --git a/.engine_scripts/puppet/loadCookies.js b/.engine_scripts/puppet/loadCookies.js deleted file mode 100644 index b9ab494..0000000 --- a/.engine_scripts/puppet/loadCookies.js +++ /dev/null @@ -1,60 +0,0 @@ -const fs = require('fs'); -const YAML = require('js-yaml'); - -module.exports = async (page, scenario) => { - let cookiesFromFile = []; - const cookiePath = scenario.cookiePath; - - // READ COOKIES FROM FILE IF EXISTS - if (!!cookiePath && fs.existsSync(cookiePath)) { - let content = fs.readFileSync(cookiePath); - if (cookiePath.endsWith('.json')) { - cookiesFromFile = JSON.parse(content); - } else if (cookiePath.endsWith('.yaml') || cookiePath.endsWith('.yml')) { - cookiesFromFile = YAML.load(content); - } - } - - const cookies = []; - - // MUNGE COOKIE DOMAIN - [].forEach.call(cookiesFromFile, (c) => { - let domains = typeof c.domain === 'string' ? [c.domain] : c.domain; - - [].forEach.call(domains, (domain) => { - const cookie = { ...c }; - if (domain.startsWith('http://') || domain.startsWith('https://')) { - cookie.url = domain; - } else { - cookie.url = 'https://' + domain; - } - - if (!cookie.expirationDate) { - cookie.expirationDate = Date.now() / 1000 + 31536000; // 1 year from now - } - - cookie.domain = undefined; - delete cookie.domain; - - cookies.push(cookie); - }); - }); - - if (process.env.DEBUG_COOKIES === 'true') { - console.log('Restoring cookies from:', cookiePath); - console.log(JSON.stringify(cookies, null, 2)); - } - - // SET COOKIES - const setCookies = async () => { - return Promise.all( - cookies.map(async (cookie) => { - await page.setCookie(cookie); - }) - ); - }; - await setCookies(); - - // console.log('Cookie state restored with:', JSON.stringify(cookies, null, 2)); - console.log('Cookie state restored for: ' + scenario.label); -}; diff --git a/.engine_scripts/puppet/loadCookies.ts b/.engine_scripts/puppet/loadCookies.ts new file mode 100644 index 0000000..50a8ee4 --- /dev/null +++ b/.engine_scripts/puppet/loadCookies.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs'; +import * as YAML from 'js-yaml'; +import type { Page } from 'puppeteer'; +import type { CookieInput, EngineScenario, PuppetLoadCookies } from '../engine.js'; + +export default (async (page: Page, scenario: EngineScenario): Promise => { + let cookiesFromFile: CookieInput[] = []; + const cookiePath = scenario.cookiePath; + + // READ COOKIES FROM FILE IF EXISTS + if (!!cookiePath && fs.existsSync(cookiePath)) { + let content = fs.readFileSync(cookiePath); + if (cookiePath.endsWith('.json')) { + cookiesFromFile = JSON.parse(content.toString()); + } else if (cookiePath.endsWith('.yaml') || cookiePath.endsWith('.yml')) { + cookiesFromFile = YAML.load(content.toString()) as CookieInput[]; + } + } + + const cookies: Array> = []; + + // MUNGE COOKIE DOMAIN + [].forEach.call(cookiesFromFile, (c: CookieInput) => { + let domains = typeof c.domain === 'string' ? [c.domain] : c.domain; + if (!domains || domains.length === 0) { + return; + } + + [].forEach.call(domains, (domain: string) => { + const cookie: Record = { ...c }; + if (domain.startsWith('http://') || domain.startsWith('https://')) { + cookie['url'] = domain; + } else { + cookie['url'] = 'https://' + domain; + } + + if (!cookie['expirationDate']) { + cookie['expirationDate'] = Date.now() / 1000 + 31536000; // 1 year from now + } + + cookie['domain'] = undefined; + delete cookie['domain']; + + cookies.push(cookie); + }); + }); + + if (process.env.DEBUG_COOKIES === 'true') { + console.log('Restoring cookies from:', cookiePath); + console.log(JSON.stringify(cookies, null, 2)); + } + + // SET COOKIES + const setCookies = async (): Promise => { + return Promise.all( + cookies.map(async (cookie) => { + await page.setCookie(cookie as unknown as Parameters[0]); + }) + ).then(() => undefined); + }; + await setCookies(); + + // console.log('Cookie state restored with:', JSON.stringify(cookies, null, 2)); + console.log('Cookie state restored for: ' + scenario.label); +}) as PuppetLoadCookies; diff --git a/.engine_scripts/puppet/onBefore.js b/.engine_scripts/puppet/onBefore.js deleted file mode 100644 index f163c2d..0000000 --- a/.engine_scripts/puppet/onBefore.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async (page, scenario, viewport, isReference, browserContext) => { - await require('./loadCookies')(browserContext, scenario); -}; diff --git a/.engine_scripts/puppet/onBefore.ts b/.engine_scripts/puppet/onBefore.ts new file mode 100644 index 0000000..c03b311 --- /dev/null +++ b/.engine_scripts/puppet/onBefore.ts @@ -0,0 +1,7 @@ +import type { Page } from 'puppeteer'; +import type { Viewport } from 'backstopjs'; +import type { EngineScenario, PuppetOnBeforeScript, PuppetLoadCookies } from '../engine.js'; + +export default (async (page: Page, scenario: EngineScenario, viewport: Viewport, isReference: boolean, browserContext: Page): Promise => { + await (require('./loadCookies') as PuppetLoadCookies)(browserContext, scenario); +}) as PuppetOnBeforeScript; diff --git a/.engine_scripts/puppet/onReady.js b/.engine_scripts/puppet/onReady.js deleted file mode 100644 index e8eb0dc..0000000 --- a/.engine_scripts/puppet/onReady.js +++ /dev/null @@ -1,32 +0,0 @@ -const fs = require('fs'); -const autoScroll = require('../auto-scroll'); - -module.exports = async (page, scenario, vp) => { - console.log('SCENARIO > ' + scenario.label); - if (scenario.useCssOverride) { - await require('./overrideCSS')(page, scenario); - } - await require('./clickAndHoverHelper')(page, scenario); - - const jsOnReadyPath = scenario.jsOnReadyPath; - - if (!jsOnReadyPath) { - return; - } else if (!fs.existsSync(jsOnReadyPath)) { - console.log('File not exist: ' + jsOnReadyPath); - return; - } - - const jsOnReadyScript = fs.readFileSync(jsOnReadyPath, 'utf-8'); - await page - .evaluate(jsOnReadyScript) - .then(() => "ONREADY script executed for: " + scenario.label); - - // add more ready handlers here... - await page.evaluate(autoScroll); - - await page.waitForNetworkIdle({ - idleTime: 500, - timeout: 5000 - }); -}; diff --git a/.engine_scripts/puppet/onReady.ts b/.engine_scripts/puppet/onReady.ts new file mode 100644 index 0000000..1d7f7fc --- /dev/null +++ b/.engine_scripts/puppet/onReady.ts @@ -0,0 +1,32 @@ +import * as fs from 'fs'; +import type { Page } from 'puppeteer'; +import type { Viewport } from 'backstopjs'; +import type { EngineScenario, PuppetOnReadyScript, PuppetOverrideCSS, PuppetClickAndHoverHelper } from '../engine.js'; +import autoScroll from '../auto-scroll.js'; + +export default (async (page: Page, scenario: EngineScenario, vp: Viewport): Promise => { + console.log('SCENARIO > ' + scenario.label); + if (scenario.useCssOverride) { + await (require('./overrideCSS') as PuppetOverrideCSS)(page, scenario); + } + await (require('./clickAndHoverHelper') as PuppetClickAndHoverHelper)(page, scenario); + + const jsOnReadyPath = scenario.jsOnReadyPath; + + if (jsOnReadyPath) { + if (!fs.existsSync(jsOnReadyPath)) { + console.log('File not exist: ' + jsOnReadyPath); + } else { + const jsOnReadyScript = fs.readFileSync(jsOnReadyPath, 'utf-8'); + await page.evaluate(jsOnReadyScript).then(() => 'ONREADY script executed for: ' + scenario.label); + } + } + + // add more ready handlers here... + await page.evaluate(autoScroll as Parameters[0]); + + await page.waitForNetworkIdle({ + idleTime: 500, + timeout: 5000, + }); +}) as PuppetOnReadyScript; diff --git a/.engine_scripts/puppet/overrideCSS.js b/.engine_scripts/puppet/overrideCSS.ts similarity index 68% rename from .engine_scripts/puppet/overrideCSS.js rename to .engine_scripts/puppet/overrideCSS.ts index 627660f..a466165 100644 --- a/.engine_scripts/puppet/overrideCSS.js +++ b/.engine_scripts/puppet/overrideCSS.ts @@ -1,7 +1,8 @@ -const fs = require('fs'); -const path = require('path'); +import * as fs from 'fs'; +import type { Page } from 'puppeteer'; +import type { EngineScenario, PuppetOverrideCSS } from '../engine.js'; -module.exports = async (page, scenario) => { +export default (async (page: Page, scenario: EngineScenario): Promise => { const cssOverridePath = scenario.cssOverridePath; if (!cssOverridePath) { @@ -18,11 +19,11 @@ module.exports = async (page, scenario) => { await page.evaluate(() => { const style = document.createElement('style'); style.type = 'text/css'; - const styleNode = document.createTextNode(window._styleData); + const styleNode = document.createTextNode(window._styleData!); style.appendChild(styleNode); document.head.appendChild(style); }); console.log('BACKSTOP_TEST_CSS_OVERRIDE injected for: ' + scenario.label); // console.log(override); -}; +}) as PuppetOverrideCSS; diff --git a/.engine_scripts/scroll-top.js b/.engine_scripts/scroll-top.ts similarity index 78% rename from .engine_scripts/scroll-top.js rename to .engine_scripts/scroll-top.ts index 7f48460..7bbeec6 100644 --- a/.engine_scripts/scroll-top.js +++ b/.engine_scripts/scroll-top.ts @@ -1,7 +1,7 @@ -module.exports = async () => { +export default async (): Promise => { const SCROLL_TOP_MAX = 100; - await new Promise((resolve) => { + await new Promise((resolve) => { const timer = setInterval(() => { if (!window.visualTestScrollingBottom) { clearInterval(timer); @@ -11,7 +11,7 @@ module.exports = async () => { }, 100); }); - await new Promise((resolve) => { + await new Promise((resolve) => { let counter = 0; const timer = setInterval(() => { if (window.scrollY === 0 || counter > SCROLL_TOP_MAX) { diff --git a/.gitignore b/.gitignore index 0819b01..f45813c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ backstop_data/ src/**/*.js src/**/*.d.ts src/**/*.map +.engine_scripts/**/*.js +.engine_scripts/**/*.map *.tsbuildinfo .states/ visual_tests/ diff --git a/package.json b/package.json index f91f3bd..95b4656 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "regressify": "./src/index.js" }, "scripts": { - "build": "tsc --project tsconfig.json", - "typecheck": "tsc --project tsconfig.json --noEmit", + "build": "tsc --project tsconfig.json && tsc --project tsconfig.engine.json && tsx scripts/fix-engine-exports.ts", + "build:engine": "tsc --project tsconfig.engine.json && tsx scripts/fix-engine-exports.ts", + "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.engine.json --noEmit", "install:browsers": "tsx src/index.ts install", "ref": "tsx src/index.ts ref", "approve": "tsx src/index.ts approve", @@ -19,7 +20,8 @@ "test:coverage": "vitest run --coverage", "test:ci": "vitest run --coverage --reporter=dot", "snapshot": "tsx src/index.ts snapshot", - "ver": "bun version" + "ver": "bun version", + "prepare": "tsc --project tsconfig.engine.json && tsx scripts/fix-engine-exports.ts" }, "repository": { "type": "git", diff --git a/scripts/fix-engine-exports.ts b/scripts/fix-engine-exports.ts new file mode 100644 index 0000000..b8daa68 --- /dev/null +++ b/scripts/fix-engine-exports.ts @@ -0,0 +1,47 @@ +/** + * Post-build step for the BackstopJS engine scripts. + * + * The engine `.ts` sources use idiomatic `export default`, which `tsc` compiles to + * `exports.default = fn` plus an `__esModule` marker. BackstopJS loads engine scripts + * via CommonJS `require()` and calls the result directly, so it needs `module.exports` + * to be the function itself, not an `{ __esModule, default }` wrapper object. + * + * This appends a guarded unwrap to every compiled engine script: when the module was + * emitted as an ES-module interop object, `module.exports` is collapsed to its default + * export. Run automatically after `tsc` by the `build:engine` npm script. + */ +import { globSync } from 'glob'; +import * as fs from 'fs'; + +const MARKER = '/* regressify:cjs-default-export */'; +const SOURCE_MAP_COMMENT = '//# sourceMappingURL='; + +const SNIPPET = `${MARKER} +if (module.exports && module.exports.__esModule && 'default' in module.exports) { + module.exports = module.exports.default; +} +`; + +const files = globSync('.engine_scripts/**/*.js', { ignore: 'node_modules/**' }); + +let patched = 0; + +for (const file of files) { + const content = fs.readFileSync(file, 'utf-8'); + + if (content.includes(MARKER)) { + continue; + } + + if (!content.includes('__esModule')) { + continue; + } + + const mapIndex = content.indexOf(SOURCE_MAP_COMMENT); + const updated = mapIndex >= 0 ? content.slice(0, mapIndex) + SNIPPET + content.slice(mapIndex) : content.replace(/\s*$/, '\n') + SNIPPET; + + fs.writeFileSync(file, updated); + patched++; +} + +console.log(`[fix-engine-exports] unwrapped default export in ${patched} of ${files.length} compiled engine script(s)`); diff --git a/tsconfig.engine.json b/tsconfig.engine.json new file mode 100644 index 0000000..3b4234d --- /dev/null +++ b/tsconfig.engine.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "node16", //Frozen configuration - Upgrading TypeScript won't silently change how the engine scripts compile + "moduleResolution": "node16", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "isolatedModules": false, + "composite": false, + "declaration": false, + "sourceMap": true, + "noEmit": false, + "rootDir": ".engine_scripts", + "outDir": ".engine_scripts" + }, + "include": [".engine_scripts/**/*.ts", ".engine_scripts/**/*.d.ts"], + "exclude": ["node_modules"] +}