From 9607d8325deb741600574d94ea6d9b07eeafb86e Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Fri, 22 May 2026 17:17:14 +0700 Subject: [PATCH 1/7] feat: migrate js to ts --- .../{auto-scroll.js => auto-scroll.ts} | 12 +-- .engine_scripts/engine.d.ts | 101 ++++++++++++++++++ .engine_scripts/engine.globals.d.ts | 8 ++ .../playwright/{actions.js => actions.ts} | 43 ++++---- .../clickAndHoverHelper.ts} | 23 ++-- .../{embedFiles.js => embedFiles.ts} | 15 +-- ...{interceptImages.js => interceptImages.ts} | 14 +-- .../{loadCookies.js => loadCookies.ts} | 25 +++-- .../playwright/{onBefore.js => onBefore.ts} | 16 ++- .engine_scripts/playwright/onReady.js | 38 ------- .engine_scripts/playwright/onReady.ts | 48 +++++++++ .../{overrideCSS.js => overrideCSS.ts} | 10 +- .../clickAndHoverHelper.ts} | 23 ++-- .../puppet/{ignoreCSP.js => ignoreCSP.ts} | 33 ++++-- ...{interceptImages.js => interceptImages.ts} | 14 +-- .engine_scripts/puppet/loadCookies.js | 60 ----------- .engine_scripts/puppet/loadCookies.ts | 62 +++++++++++ .engine_scripts/puppet/onBefore.js | 3 - .engine_scripts/puppet/onBefore.ts | 7 ++ .engine_scripts/puppet/onReady.js | 32 ------ .engine_scripts/puppet/onReady.ts | 33 ++++++ .../puppet/{overrideCSS.js => overrideCSS.ts} | 11 +- .../{scroll-top.js => scroll-top.ts} | 6 +- .gitignore | 2 + package.json | 5 +- scripts/fix-engine-exports.ts | 50 +++++++++ tsconfig.engine.json | 23 ++++ 27 files changed, 486 insertions(+), 231 deletions(-) rename .engine_scripts/{auto-scroll.js => auto-scroll.ts} (83%) create mode 100644 .engine_scripts/engine.d.ts create mode 100644 .engine_scripts/engine.globals.d.ts rename .engine_scripts/playwright/{actions.js => actions.ts} (80%) rename .engine_scripts/{puppet/clickAndHoverHelper.js => playwright/clickAndHoverHelper.ts} (52%) rename .engine_scripts/playwright/{embedFiles.js => embedFiles.ts} (59%) rename .engine_scripts/playwright/{interceptImages.js => interceptImages.ts} (67%) rename .engine_scripts/playwright/{loadCookies.js => loadCookies.ts} (61%) rename .engine_scripts/playwright/{onBefore.js => onBefore.ts} (50%) delete mode 100644 .engine_scripts/playwright/onReady.js create mode 100644 .engine_scripts/playwright/onReady.ts rename .engine_scripts/playwright/{overrideCSS.js => overrideCSS.ts} (76%) rename .engine_scripts/{playwright/clickAndHoverHelper.js => puppet/clickAndHoverHelper.ts} (55%) rename .engine_scripts/puppet/{ignoreCSP.js => ignoreCSP.ts} (66%) rename .engine_scripts/puppet/{interceptImages.js => interceptImages.ts} (67%) delete mode 100644 .engine_scripts/puppet/loadCookies.js create mode 100644 .engine_scripts/puppet/loadCookies.ts delete mode 100644 .engine_scripts/puppet/onBefore.js create mode 100644 .engine_scripts/puppet/onBefore.ts delete mode 100644 .engine_scripts/puppet/onReady.js create mode 100644 .engine_scripts/puppet/onReady.ts rename .engine_scripts/puppet/{overrideCSS.js => overrideCSS.ts} (68%) rename .engine_scripts/{scroll-top.js => scroll-top.ts} (78%) create mode 100644 scripts/fix-engine-exports.ts create mode 100644 tsconfig.engine.json 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 80% rename from .engine_scripts/playwright/actions.js rename to .engine_scripts/playwright/actions.ts index 186e9d7..901c936 100644 --- a/.engine_scripts/playwright/actions.js +++ b/.engine_scripts/playwright/actions.ts @@ -1,9 +1,12 @@ -const fs = require('fs'); -const path = require('path'); -const YAML = require('js-yaml'); +import * as fs from 'fs'; +import * as path from 'path'; +import * as YAML from 'js-yaml'; +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 +17,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 +28,8 @@ 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; + page = (await handle.contentFrame())!; } } @@ -79,7 +82,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 +92,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,7 +108,7 @@ module.exports = async (context) => { }); if (action.useFileChooser) { - const fileChooserPromise = page.waitForEvent('filechooser'); + const fileChooserPromise = (page as Page).waitForEvent('filechooser'); el.click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(normalizedPaths); @@ -118,21 +121,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.hide as string); 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 +172,13 @@ 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); + if (parseInt(String(action.wait)) > 0) { + await page.waitForTimeout(action.wait as number); } else { - await page.waitForSelector(action.wait); + await page.waitForSelector(action.wait as string); } } @@ -184,4 +187,4 @@ module.exports = async (context) => { await browserContext.storageState({ path: action.path }); } } -}; +}) as Actions; diff --git a/.engine_scripts/puppet/clickAndHoverHelper.js b/.engine_scripts/playwright/clickAndHoverHelper.ts similarity index 52% rename from .engine_scripts/puppet/clickAndHoverHelper.js rename to .engine_scripts/playwright/clickAndHoverHelper.ts index d848fcf..63809b2 100644 --- a/.engine_scripts/puppet/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,34 +9,38 @@ 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) { - await page.waitForTimeout(postInteractionWait); + if (parseInt(String(postInteractionWait)) > 0) { + await page.waitForTimeout(postInteractionWait as number); + } else { + 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 67% rename from .engine_scripts/playwright/interceptImages.js rename to .engine_scripts/playwright/interceptImages.ts index 4077e76..010c220 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 => { +export default async function (page: Page, scenario: EngineScenario): Promise { + page.route(IMAGE_URL_RE, (route) => { 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..598f566 --- /dev/null +++ b/.engine_scripts/playwright/onReady.ts @@ -0,0 +1,48 @@ +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); + +export default (async ( + page: Page, + scenario: EngineScenario, + viewport: Viewport, + isReference: boolean, + browserContext: BrowserContext +): Promise => { + await (require('./embedFiles') as 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') as EmbedFiles)(scenario, data); + await data.evaluate(require('../auto-scroll') as BrowserScript); + } catch (error) { + console.log(logPrefix + error); + } + }); + + console.log(logPrefix + 'SCENARIO > ' + scenario.label); + + if (!!scenario.actions) { + await (require('./actions') as Actions)({ currentPage: page, scenario, browserContext }); + } else { + await (require('./clickAndHoverHelper') as 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 as number); + } +}) 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/playwright/clickAndHoverHelper.js b/.engine_scripts/puppet/clickAndHoverHelper.ts similarity index 55% rename from .engine_scripts/playwright/clickAndHoverHelper.js rename to .engine_scripts/puppet/clickAndHoverHelper.ts index 8a0e8f0..d052ead 100644 --- a/.engine_scripts/playwright/clickAndHoverHelper.js +++ b/.engine_scripts/puppet/clickAndHoverHelper.ts @@ -1,4 +1,7 @@ -module.exports = async (page, scenario) => { +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; @@ -6,38 +9,34 @@ 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); - } else { - await page.waitForSelector(postInteractionWait); - } + await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait as number); } 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 PuppetClickAndHoverHelper; diff --git a/.engine_scripts/puppet/ignoreCSP.js b/.engine_scripts/puppet/ignoreCSP.ts similarity index 66% rename from .engine_scripts/puppet/ignoreCSP.js rename to .engine_scripts/puppet/ignoreCSP.ts index 36851fb..61c7649 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 { @@ -60,6 +79,6 @@ module.exports = async function (page, scenario) { await page.setRequestInterception(true); page.on('request', (req) => { - intercept(req, scenario.url); + 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..b5a7902 --- /dev/null +++ b/.engine_scripts/puppet/loadCookies.ts @@ -0,0 +1,62 @@ +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; + + [].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..74030c2 --- /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: unknown): Promise => { + await (require('./loadCookies') as PuppetLoadCookies)(browserContext as Page, 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..acdf7c6 --- /dev/null +++ b/.engine_scripts/puppet/onReady.ts @@ -0,0 +1,33 @@ +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) { + 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 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..d9d8e73 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", diff --git a/scripts/fix-engine-exports.ts b/scripts/fix-engine-exports.ts new file mode 100644 index 0000000..0fb27ca --- /dev/null +++ b/scripts/fix-engine-exports.ts @@ -0,0 +1,50 @@ +/** + * 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..e9108e9 --- /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"], + "exclude": ["node_modules"] +} From fb49be47753f2f13918613a16d3706a48a172022 Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Mon, 25 May 2026 13:19:40 +0700 Subject: [PATCH 2/7] feat: migrate js to ts --- .engine_scripts/engine.d.ts | 4 ++-- .engine_scripts/playwright/actions.ts | 3 +-- .engine_scripts/playwright/clickAndHoverHelper.ts | 6 +----- .engine_scripts/playwright/interceptImages.ts | 4 ++-- .engine_scripts/playwright/onReady.ts | 2 +- .engine_scripts/puppet/clickAndHoverHelper.ts | 2 +- .engine_scripts/puppet/ignoreCSP.ts | 4 ++-- .engine_scripts/puppet/loadCookies.ts | 3 +++ .engine_scripts/puppet/onBefore.ts | 4 ++-- .engine_scripts/puppet/onReady.ts | 15 +++++++-------- tsconfig.engine.json | 2 +- 11 files changed, 23 insertions(+), 26 deletions(-) diff --git a/.engine_scripts/engine.d.ts b/.engine_scripts/engine.d.ts index 85b63d7..88b0af0 100644 --- a/.engine_scripts/engine.d.ts +++ b/.engine_scripts/engine.d.ts @@ -28,7 +28,7 @@ export interface ScenarioAction { label?: string; uncheck?: string; wait?: number | string; - url?: string; + url: string; persist?: string; path?: string; } @@ -44,7 +44,7 @@ export interface EngineScenario extends Scenario { cssOverridePath?: string; jsOnReadyPath?: string; noScrollTop?: boolean; - postInteractionWait?: number | string; + postInteractionWait?: number; hoverSelector?: string | string[]; hoverSelectors?: string | string[]; clickSelector?: string | string[]; diff --git a/.engine_scripts/playwright/actions.ts b/.engine_scripts/playwright/actions.ts index 901c936..7e84413 100644 --- a/.engine_scripts/playwright/actions.ts +++ b/.engine_scripts/playwright/actions.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as YAML from 'js-yaml'; import type { Page, Frame, ElementHandle } from 'playwright'; import type { ActionsContext, ScenarioAction, Actions } from '../engine.js'; @@ -175,7 +174,7 @@ export default (async (context: ActionsContext): Promise => { await (page as Page).waitForURL(url); } - if (parseInt(String(action.wait)) > 0) { + if (parseInt(String(action.wait)) > 0 && !Number.isNaN(parseInt(String(action.wait)))) { await page.waitForTimeout(action.wait as number); } else { await page.waitForSelector(action.wait as string); diff --git a/.engine_scripts/playwright/clickAndHoverHelper.ts b/.engine_scripts/playwright/clickAndHoverHelper.ts index 63809b2..6bcea31 100644 --- a/.engine_scripts/playwright/clickAndHoverHelper.ts +++ b/.engine_scripts/playwright/clickAndHoverHelper.ts @@ -30,11 +30,7 @@ export default (async (page: Page | Frame, scenario: EngineScenario): Promise 0) { - await page.waitForTimeout(postInteractionWait as number); - } else { - await page.waitForSelector(postInteractionWait as string); - } + await page.waitForTimeout(postInteractionWait); } if (scrollToSelector) { diff --git a/.engine_scripts/playwright/interceptImages.ts b/.engine_scripts/playwright/interceptImages.ts index 010c220..65ff22c 100644 --- a/.engine_scripts/playwright/interceptImages.ts +++ b/.engine_scripts/playwright/interceptImages.ts @@ -23,8 +23,8 @@ const IMAGE_DATA_BUFFER = fs.readFileSync(IMAGE_STUB_URL); const HEADERS_STUB = {}; export default async function (page: Page, scenario: EngineScenario): Promise { - page.route(IMAGE_URL_RE, (route) => { - route.fulfill({ + await page.route(IMAGE_URL_RE, async (route) => { + await route.fulfill({ body: IMAGE_DATA_BUFFER, headers: HEADERS_STUB, status: 200, diff --git a/.engine_scripts/playwright/onReady.ts b/.engine_scripts/playwright/onReady.ts index 598f566..71795f1 100644 --- a/.engine_scripts/playwright/onReady.ts +++ b/.engine_scripts/playwright/onReady.ts @@ -43,6 +43,6 @@ export default (async ( // await page.waitForLoadState('load', { timeout: 5000 }); if (scenario.postInteractionWait) { - await page.waitForTimeout(scenario.postInteractionWait as number); + await page.waitForTimeout(scenario.postInteractionWait); } }) as OnReadyScript; diff --git a/.engine_scripts/puppet/clickAndHoverHelper.ts b/.engine_scripts/puppet/clickAndHoverHelper.ts index d052ead..e4c3818 100644 --- a/.engine_scripts/puppet/clickAndHoverHelper.ts +++ b/.engine_scripts/puppet/clickAndHoverHelper.ts @@ -30,7 +30,7 @@ export default (async (page: Page, scenario: EngineScenario): Promise => { } if (postInteractionWait) { - await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait as number); + await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait); } if (scrollToSelector) { diff --git a/.engine_scripts/puppet/ignoreCSP.ts b/.engine_scripts/puppet/ignoreCSP.ts index 61c7649..69b6fe1 100644 --- a/.engine_scripts/puppet/ignoreCSP.ts +++ b/.engine_scripts/puppet/ignoreCSP.ts @@ -78,7 +78,7 @@ export default async function (page: Page, scenario: EngineScenario): Promise { - intercept(req, scenario.url!); + page.on('request', async (req) => { + await intercept(req, scenario.url); }); } diff --git a/.engine_scripts/puppet/loadCookies.ts b/.engine_scripts/puppet/loadCookies.ts index b5a7902..50a8ee4 100644 --- a/.engine_scripts/puppet/loadCookies.ts +++ b/.engine_scripts/puppet/loadCookies.ts @@ -22,6 +22,9 @@ export default (async (page: Page, scenario: EngineScenario): Promise => { // 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 }; diff --git a/.engine_scripts/puppet/onBefore.ts b/.engine_scripts/puppet/onBefore.ts index 74030c2..c03b311 100644 --- a/.engine_scripts/puppet/onBefore.ts +++ b/.engine_scripts/puppet/onBefore.ts @@ -2,6 +2,6 @@ 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: unknown): Promise => { - await (require('./loadCookies') as PuppetLoadCookies)(browserContext as Page, scenario); +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.ts b/.engine_scripts/puppet/onReady.ts index acdf7c6..1d7f7fc 100644 --- a/.engine_scripts/puppet/onReady.ts +++ b/.engine_scripts/puppet/onReady.ts @@ -13,16 +13,15 @@ export default (async (page: Page, scenario: EngineScenario, vp: Viewport): Prom const jsOnReadyPath = scenario.jsOnReadyPath; - if (!jsOnReadyPath) { - return; - } else if (!fs.existsSync(jsOnReadyPath)) { - console.log('File not exist: ' + jsOnReadyPath); - return; + 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); + } } - 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]); diff --git a/tsconfig.engine.json b/tsconfig.engine.json index e9108e9..3b4234d 100644 --- a/tsconfig.engine.json +++ b/tsconfig.engine.json @@ -18,6 +18,6 @@ "rootDir": ".engine_scripts", "outDir": ".engine_scripts" }, - "include": [".engine_scripts/**/*.ts"], + "include": [".engine_scripts/**/*.ts", ".engine_scripts/**/*.d.ts"], "exclude": ["node_modules"] } From a508680648ab8c4925691ec427872ad20fbbd5e1 Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Mon, 25 May 2026 14:13:56 +0700 Subject: [PATCH 3/7] feat: migrate js to ts --- .engine_scripts/engine.d.ts | 4 ++-- .engine_scripts/playwright/actions.ts | 3 ++- .engine_scripts/playwright/clickAndHoverHelper.ts | 7 ++++++- .engine_scripts/playwright/onReady.ts | 7 ++++++- .engine_scripts/puppet/clickAndHoverHelper.ts | 7 ++++++- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.engine_scripts/engine.d.ts b/.engine_scripts/engine.d.ts index 88b0af0..85b63d7 100644 --- a/.engine_scripts/engine.d.ts +++ b/.engine_scripts/engine.d.ts @@ -28,7 +28,7 @@ export interface ScenarioAction { label?: string; uncheck?: string; wait?: number | string; - url: string; + url?: string; persist?: string; path?: string; } @@ -44,7 +44,7 @@ export interface EngineScenario extends Scenario { cssOverridePath?: string; jsOnReadyPath?: string; noScrollTop?: boolean; - postInteractionWait?: number; + postInteractionWait?: number | string; hoverSelector?: string | string[]; hoverSelectors?: string | string[]; clickSelector?: string | string[]; diff --git a/.engine_scripts/playwright/actions.ts b/.engine_scripts/playwright/actions.ts index 7e84413..9253307 100644 --- a/.engine_scripts/playwright/actions.ts +++ b/.engine_scripts/playwright/actions.ts @@ -174,7 +174,8 @@ export default (async (context: ActionsContext): Promise => { await (page as Page).waitForURL(url); } - if (parseInt(String(action.wait)) > 0 && !Number.isNaN(parseInt(String(action.wait)))) { + const waitTimeMs = parseInt(String(action.wait)); + if (!Number.isNaN(waitTimeMs) && waitTimeMs > 0) { await page.waitForTimeout(action.wait as number); } else { await page.waitForSelector(action.wait as string); diff --git a/.engine_scripts/playwright/clickAndHoverHelper.ts b/.engine_scripts/playwright/clickAndHoverHelper.ts index 6bcea31..6a384b1 100644 --- a/.engine_scripts/playwright/clickAndHoverHelper.ts +++ b/.engine_scripts/playwright/clickAndHoverHelper.ts @@ -30,7 +30,12 @@ export default (async (page: Page | Frame, scenario: EngineScenario): Promise 0) { + await page.waitForTimeout(postInteractionWait as number); + } else { + await page.waitForSelector(postInteractionWait as string); + } } if (scrollToSelector) { diff --git a/.engine_scripts/playwright/onReady.ts b/.engine_scripts/playwright/onReady.ts index 71795f1..2dab488 100644 --- a/.engine_scripts/playwright/onReady.ts +++ b/.engine_scripts/playwright/onReady.ts @@ -43,6 +43,11 @@ export default (async ( // await page.waitForLoadState('load', { timeout: 5000 }); if (scenario.postInteractionWait) { - await page.waitForTimeout(scenario.postInteractionWait); + const interactionWait = parseInt(String(scenario.postInteractionWait)); + if (!Number.isNaN(interactionWait) && interactionWait > 0) { + await page.waitForTimeout(scenario.postInteractionWait as number); + } else { + await page.waitForSelector(scenario.postInteractionWait as string); + } } }) as OnReadyScript; diff --git a/.engine_scripts/puppet/clickAndHoverHelper.ts b/.engine_scripts/puppet/clickAndHoverHelper.ts index e4c3818..89513b4 100644 --- a/.engine_scripts/puppet/clickAndHoverHelper.ts +++ b/.engine_scripts/puppet/clickAndHoverHelper.ts @@ -30,7 +30,12 @@ export default (async (page: Page, scenario: EngineScenario): Promise => { } if (postInteractionWait) { - await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait); + const interactionWait = parseInt(String(postInteractionWait)); + if (!Number.isNaN(interactionWait) && interactionWait > 0) { + await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait as number); + } else { + await page.waitForSelector(postInteractionWait as string); + } } if (scrollToSelector) { From 65f4da1640d4c310d487b26603a7fd466a646284 Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Mon, 25 May 2026 14:30:29 +0700 Subject: [PATCH 4/7] feat: migrate js to ts --- .engine_scripts/playwright/clickAndHoverHelper.ts | 4 ++-- .engine_scripts/playwright/onReady.ts | 4 ++-- .engine_scripts/puppet/clickAndHoverHelper.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.engine_scripts/playwright/clickAndHoverHelper.ts b/.engine_scripts/playwright/clickAndHoverHelper.ts index 6a384b1..b8d0af8 100644 --- a/.engine_scripts/playwright/clickAndHoverHelper.ts +++ b/.engine_scripts/playwright/clickAndHoverHelper.ts @@ -31,8 +31,8 @@ export default (async (page: Page | Frame, scenario: EngineScenario): Promise 0) { - await page.waitForTimeout(postInteractionWait as number); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await page.waitForTimeout(interactionWait); } else { await page.waitForSelector(postInteractionWait as string); } diff --git a/.engine_scripts/playwright/onReady.ts b/.engine_scripts/playwright/onReady.ts index 2dab488..92cfd83 100644 --- a/.engine_scripts/playwright/onReady.ts +++ b/.engine_scripts/playwright/onReady.ts @@ -44,8 +44,8 @@ export default (async ( if (scenario.postInteractionWait) { const interactionWait = parseInt(String(scenario.postInteractionWait)); - if (!Number.isNaN(interactionWait) && interactionWait > 0) { - await page.waitForTimeout(scenario.postInteractionWait as number); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await page.waitForTimeout(interactionWait); } else { await page.waitForSelector(scenario.postInteractionWait as string); } diff --git a/.engine_scripts/puppet/clickAndHoverHelper.ts b/.engine_scripts/puppet/clickAndHoverHelper.ts index 89513b4..e078a5e 100644 --- a/.engine_scripts/puppet/clickAndHoverHelper.ts +++ b/.engine_scripts/puppet/clickAndHoverHelper.ts @@ -31,8 +31,8 @@ export default (async (page: Page, scenario: EngineScenario): Promise => { if (postInteractionWait) { const interactionWait = parseInt(String(postInteractionWait)); - if (!Number.isNaN(interactionWait) && interactionWait > 0) { - await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(postInteractionWait as number); + if (!Number.isNaN(interactionWait) && interactionWait >= 0) { + await (page as unknown as { waitForTimeout(ms: number): Promise }).waitForTimeout(interactionWait); } else { await page.waitForSelector(postInteractionWait as string); } From 541d36703ba2f4ccc02f66175926b6f24391b3ad Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Mon, 25 May 2026 15:55:53 +0700 Subject: [PATCH 5/7] feat: migrate js to ts --- .engine_scripts/playwright/actions.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.engine_scripts/playwright/actions.ts b/.engine_scripts/playwright/actions.ts index 9253307..d7870fe 100644 --- a/.engine_scripts/playwright/actions.ts +++ b/.engine_scripts/playwright/actions.ts @@ -28,7 +28,14 @@ export default (async (context: ActionsContext): Promise => { for (let j = 0; j < frames.length; j++) { await page.waitForSelector(frames[j]); const handle = (await page.locator(frames[j]).elementHandle()) as ElementHandle; - page = (await handle.contentFrame())!; + 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; } } @@ -107,8 +114,8 @@ export default (async (context: ActionsContext): Promise => { }); if (action.useFileChooser) { - const fileChooserPromise = (page as Page).waitForEvent('filechooser'); - el.click(); + const fileChooserPromise = currentPage.waitForEvent('filechooser'); + await el.click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(normalizedPaths); } else { @@ -120,7 +127,7 @@ export default (async (context: ActionsContext): Promise => { if (!!action.remove) { console.log(logPrefix + 'Remove:', action.remove); await page.waitForSelector(action.remove); - let el = await page.locator(action.hide as string); + let el = await page.locator(action.remove); await el.evaluate((node) => node.style.setProperty('display', 'none', 'important')); } From a674276a6038512b62fa0f81f419268360d72868 Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Mon, 25 May 2026 17:09:53 +0700 Subject: [PATCH 6/7] feat: migrate js to ts --- .engine_scripts/playwright/actions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.engine_scripts/playwright/actions.ts b/.engine_scripts/playwright/actions.ts index d7870fe..11ab865 100644 --- a/.engine_scripts/playwright/actions.ts +++ b/.engine_scripts/playwright/actions.ts @@ -182,8 +182,8 @@ export default (async (context: ActionsContext): Promise => { } const waitTimeMs = parseInt(String(action.wait)); - if (!Number.isNaN(waitTimeMs) && waitTimeMs > 0) { - await page.waitForTimeout(action.wait as number); + if (!Number.isNaN(waitTimeMs) && waitTimeMs >= 0) { + await page.waitForTimeout(waitTimeMs); } else { await page.waitForSelector(action.wait as string); } From 2ab5614d24bab9397b1b8160afcc1947dc0ef673 Mon Sep 17 00:00:00 2001 From: Quan Vu Date: Tue, 26 May 2026 16:54:43 +0700 Subject: [PATCH 7/7] feat: add prepare script --- .engine_scripts/playwright/onReady.ts | 14 +++++++++----- package.json | 3 ++- scripts/fix-engine-exports.ts | 5 +---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.engine_scripts/playwright/onReady.ts b/.engine_scripts/playwright/onReady.ts index 92cfd83..542f00c 100644 --- a/.engine_scripts/playwright/onReady.ts +++ b/.engine_scripts/playwright/onReady.ts @@ -5,6 +5,10 @@ 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, @@ -13,15 +17,15 @@ export default (async ( isReference: boolean, browserContext: BrowserContext ): Promise => { - await (require('./embedFiles') as EmbedFiles)(scenario, page); + 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 (require('./embedFiles') as EmbedFiles)(scenario, data); - await data.evaluate(require('../auto-scroll') as BrowserScript); + await embedFiles(scenario, data); + await data.evaluate(autoScrolls); } catch (error) { console.log(logPrefix + error); } @@ -30,9 +34,9 @@ export default (async ( console.log(logPrefix + 'SCENARIO > ' + scenario.label); if (!!scenario.actions) { - await (require('./actions') as Actions)({ currentPage: page, scenario, browserContext }); + await actions({ currentPage: page, scenario, browserContext }); } else { - await (require('./clickAndHoverHelper') as ClickAndHoverHelper)(page, scenario); + await clickAndHoverHelper(page, scenario); } if (!scenario.noScrollTop) { diff --git a/package.json b/package.json index d9d8e73..95b4656 100644 --- a/package.json +++ b/package.json @@ -20,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 index 0fb27ca..b8daa68 100644 --- a/scripts/fix-engine-exports.ts +++ b/scripts/fix-engine-exports.ts @@ -38,10 +38,7 @@ for (const file of files) { } const mapIndex = content.indexOf(SOURCE_MAP_COMMENT); - const updated = - mapIndex >= 0 - ? content.slice(0, mapIndex) + SNIPPET + content.slice(mapIndex) - : content.replace(/\s*$/, '\n') + SNIPPET; + const updated = mapIndex >= 0 ? content.slice(0, mapIndex) + SNIPPET + content.slice(mapIndex) : content.replace(/\s*$/, '\n') + SNIPPET; fs.writeFileSync(file, updated); patched++;