diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 00000000..190e54fe --- /dev/null +++ b/.github/README.md @@ -0,0 +1,22 @@ +# SSH into GH actions +## action code +If you need to debug your action you can add this snippet to the workflow: +``` +- name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true +``` +once you push to the branch you can find an ssh connection string in action logs. + +## github account setup +you will need to have generated pair of SSH keys on your machine. +Follow this docu: https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account +check for existing keys, if doesn't exist generate a pair and add a public one to your github account. + +## SSH out! +If you are in the office network or using VPN - don't forget to SSH out: +1. Go to this site +http://sanjose-ssh-out.corp.adobe.com/ (HTTP not HTTPS) +2. Log in using your adobenet ID and "One time password token" +3. After logging in, you should get the confirmation message saying that "Authentication successfully. You may now SSH or SCP to an Internet host...." \ No newline at end of file diff --git a/.github/workflows/run-nala.yml b/.github/workflows/run-nala.yml new file mode 100644 index 00000000..0b2727e3 --- /dev/null +++ b/.github/workflows/run-nala.yml @@ -0,0 +1,54 @@ +name: Run Nala Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: [opened, synchronize, reopened] + +jobs: + run-nala-tests: + name: Running Nala E2E UI Tests + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Set execute permission for pr.run.sh + run: chmod +x ./nala/utils/pr.run.sh + + - name: Run Nala Tests via pr.run.sh + run: ./nala/utils/pr.run.sh + env: + labels: ${{ join(github.event.pull_request.labels.*.name, ' ') }} + branch: ${{ github.event.pull_request.head.ref }} + repoName: ${{ github.repository }} + prUrl: ${{ github.event.pull_request.head.repo.html_url }} + prOrg: ${{ github.event.pull_request.head.repo.owner.login }} + prRepo: ${{ github.event.pull_request.head.repo.name }} + prBranch: ${{ github.event.pull_request.head.ref }} + prBaseBranch: ${{ github.event.pull_request.base.ref }} + GITHUB_ACTION_PATH: ${{ github.workspace }} + IMS_EMAIL: ${{ secrets.IMS_EMAIL }} + IMS_PASS: ${{ secrets.IMS_PASS }} + + - name: Upload screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-results + path: test-results + retention-days: 7 diff --git a/.gitignore b/.gitignore index 1f076657..8cab1c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ swc.json studio.json *.key *.crt +test-html-results/ +test-results/ # IO Runtime Config config.json diff --git a/README.md b/README.md index 7ca17ef2..02bb0014 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,24 @@ Refer to the corresponding README.md under any of the packages: * studio - M@S Studio for creating, updating and publishing merch fragments * ost-audit - crawls EDS pages HTML for OST links and generates a CSV report +## Nala E2E tests +for initial setup: +``` +npm install +npx playwright install +export IMS_EMAIL= +export IMS_PASS= +``` +Ask colleagues/slack for IMS_EMAIL ad IMS_PASS values, your user might not work as expected because it's not '@adobetest.com' account. + +`npm run nala local` - to run on local +`npm run nala MWPW-160756` - to run on branch +`npm run nala MWPW-160756 mode=ui` - ui mode + +Beware that 'npm run nala' runs `node nala/utils/nala.run.js`, it's not the script that GH action does. +If you want to debug GH action script run sh `nala/utils/pr.run.sh` +# CI/CD +documented in .github/README.md #### Troubleshooting Please reach out to us in `#tacocat-friends` for any questions. \ No newline at end of file diff --git a/nala/.nala-snippets/spec-snippet.code-snippets b/nala/.nala-snippets/spec-snippet.code-snippets new file mode 100644 index 00000000..c842f061 --- /dev/null +++ b/nala/.nala-snippets/spec-snippet.code-snippets @@ -0,0 +1,25 @@ +{ + "Create Nala Spec": { + "prefix": "create nala spec", + "body": [ + "module.exports = {", + " FeatureName: '${1:Block or Feature Name}',", + " features: [", + " {", + " tcid: '0',", + " name: '@${2:spec-name}',", + " path: '/drafts/nala/[${3:test-page-path}]',", + " data: {", + " attribute-1: '${4:value}',", + " attribute-2: '${5:value}',", + " attribute-3: '${6:value}',", + " },", + " tags: '@Block @smoke @regression @dme',", + " },", + " ],", + "};" + ], + "description": "Create a Nala spec with block name or feature name" + } +} + diff --git a/nala/libs/baseurl.js b/nala/libs/baseurl.js new file mode 100644 index 00000000..8f19a059 --- /dev/null +++ b/nala/libs/baseurl.js @@ -0,0 +1,19 @@ + +import pkg from 'axios'; + +const { head } = pkg; +export async function isBranchURLValid(url) { + try { + const response = await head(url); + if (response.status === 200) { + console.info(`\nURL (${url}) returned a 200 status code. It is valid.`); + return true; + } else { + console.info(`\nURL (${url}) returned a non-200 status code (${response.status}). It is invalid.`); + return false; + } + } catch (error) { + console.info(`\nError checking URL (${url}): returned a non-200 status code (${response.status})`); + return false; + } +} diff --git a/nala/libs/imslogin.js b/nala/libs/imslogin.js new file mode 100644 index 00000000..21f8b489 --- /dev/null +++ b/nala/libs/imslogin.js @@ -0,0 +1,31 @@ +/* eslint-disable import/no-import-module-exports */ +import { expect } from '@playwright/test'; + +async function fillOutSignInForm(props, page) { + expect(process.env.IMS_EMAIL, 'ERROR: No environment variable for email provided for IMS Test.').toBeTruthy(); + expect(process.env.IMS_PASS, 'ERROR: No environment variable for password provided for IMS Test.').toBeTruthy(); + + await expect(page).toHaveTitle(/Adobe ID/); + let heading = await page.locator('.spectrum-Heading1').first().innerText(); + expect(heading).toBe('Sign in'); + + // Fill out Sign-in Form + await expect(async () => { + await page.locator('#EmailPage-EmailField').fill(process.env.IMS_EMAIL); + await page.locator('[data-id=EmailPage-ContinueButton]').click(); + await expect(page.locator('text=Reset your password')).toBeVisible({ timeout: 45000 }); // Timeout accounting for how long IMS Login page takes to switch form + }).toPass({ + intervals: [1_000], + timeout: 10_000, + }); + + heading = await page.locator('.spectrum-Heading1', { hasText: 'Enter your password' }).first().innerText(); + expect(heading).toBe('Enter your password'); + await page.locator('#PasswordPage-PasswordField').fill(process.env.IMS_PASS); + await page.locator('[data-id=PasswordPage-ContinueButton]').click(); + await page.locator('div.ActionList-Item:nth-child(1)').click(); + await page.waitForURL(`${props.url}#`); + await expect(page).toHaveURL(`${props.url}#`); +} + +export default { fillOutSignInForm }; diff --git a/nala/libs/screenshot/take.js b/nala/libs/screenshot/take.js new file mode 100644 index 00000000..6e771b50 --- /dev/null +++ b/nala/libs/screenshot/take.js @@ -0,0 +1,25 @@ +/** + * Take a screenshot of a page + * @param {Page} page - The page object + * @param {string} folderPath - The folder path to save the screenshot, e.g., screenshots/milo + * @param {string} fileName - The file name of the screenshot + * @param {object} options - The screenshot options, see https://playwright.dev/docs/api/class-page#page-screenshot + * @returns {object} The screenshot result +*/ +async function take(page, folderPath, fileName, options = {}) { + const urls = []; + const result = {}; + const name = `${folderPath}/${fileName}.png`; + urls.push(page.url()); + options.path = name; + if (options.selector) { + await page.locator(options.selector).screenshot(options); + } else { + await page.screenshot(options); + } + result.a = name; + result.urls = urls.join(' | '); + return result; +} + +export default { take }; diff --git a/nala/libs/webutil.js b/nala/libs/webutil.js new file mode 100644 index 00000000..a9c630f0 --- /dev/null +++ b/nala/libs/webutil.js @@ -0,0 +1,418 @@ + + + +// eslint-disable-next-line import/no-import-module-exports +import { expect } from '@playwright/test'; + +const fs = require('fs'); +// eslint-disable-next-line import/no-extraneous-dependencies +const yaml = require('js-yaml'); +const { request } = require('@playwright/test'); + +/** + * A utility class for common web interactions. + */ +exports.WebUtil = class WebUtil { + /** + * Create a new instance of WebUtil. + * @param {object} page - A Playwright page object. + */ + constructor(page) { + this.page = page; + this.locator = null; + } + + /** + * Check if the element associated with the current locator is visible. + * @param {Locator} locator - The Playwright locator for the element to check. + * + */ + static async isVisible(locator) { + this.locator = locator; + await expect(this.locator).toBeVisible(); + return true; + } + + /** + * Check if the element associated with the current locator is displayed. + * @param {Locator} locator - The Playwright locator for the element to check. + * @returns {Promise} - Resolves to `true` if the element is displayed, or `false`. + */ + static async isDisplayed(locator) { + this.locator = locator; + try { + return await this.locator.evaluate((e) => e.offsetWidth > 0 && e.offsetHeight > 0); + } catch (e) { + console.error(`Error checking if element is displayed for locator: ${locator.toString()}`, e); + return false; + } + } + + /** + * Click the element associated with the current locator. + * @param {Locator} locator - The Playwright locator for the element to click. + * @returns {Promise} A Promise that resolves when the element has been clicked. + */ + static async click(locator) { + this.locator = locator; + return this.locator.click(); + } + + /** + * Get the inner text of the element associated with the current locator. + * @param {Locator} locator - The Playwright locator for the element to retrieve text from. + * @returns {Promise} A Promise that resolves to the inner text of the element. + */ + static async getInnerText(locator) { + this.locator = locator; + const innerText = await this.locator.innerText(); + return innerText; + } + + /** + * Get the text of the element associated with the current locator, filtered by the specified tag name. + * @param {Locator} locator - The Playwright locator for the element to retrieve text from. + * @param {string} tagName - The name of the tag to filter by (e.g. "p", "span", etc.). + * @returns {Promise} A Promise that resolves to the text of the element, filtered by the specified tag name. + */ + static async getTextByTag(locator, tagName) { + this.locator = locator; + return this.locator.$eval(tagName, (e) => e.textContent); + } + + /** + * Get the value of the specified attribute on the element associated with the current locator. + * @param {Locator} locator - The Playwright locator for the element to retrieve the attribute from. + * @param {string} attributeName - The name of the attribute to retrieve (e.g. "class", "data-attr", etc.). + * @returns {Promise} A Promise that resolves to the value of the specified attribute on the element. + */ + static async getAttribute(locator, attributeName) { + this.locator = locator; + return this.locator.getAttribute(attributeName); + } + + /** + * Verifies that the specified CSS properties of the given locator match the expected values. + * @param {Object} locator - The locator to verify CSS properties for. + * @param {Object} cssProps - The CSS properties and expected values to verify. + * @returns {Boolean} - True if all CSS properties match the expected values, false otherwise. + */ + static async verifyCSS(locator, cssProps) { + this.locator = locator; + // Verify the CSS properties and values + let result = true; + await Promise.allSettled( + Object.entries(cssProps).map(async ([property, expectedValue]) => { + try { + await expect(this.locator).toHaveCSS(property, expectedValue); + } catch (error) { + console.error(`CSS property ${property} not found:`, error); + result = false; + } + }), + ); + return result; + } + + /** + * Verifies that the specified CSS properties of the given locator match the expected values. + * @param {Object} locator - The locator to verify CSS properties for. + * @param {Object} cssProps - The CSS properties and expected values to verify. + * @returns {Boolean} - True if all CSS properties match the expected values, false otherwise. + */ + + async verifyCSS_(locator, cssProps) { + this.locator = locator; + let result = true; + await Promise.allSettled( + Object.entries(cssProps).map(async ([property, expectedValue]) => { + try { + await expect(this.locator).toHaveCSS(property, expectedValue); + } catch (error) { + console.error(`CSS property ${property} not found:`, error); + result = false; + } + }), + ); + return result; + } + + /** + * Verifies that the specified attribute properties of the given locator match the expected values. + * @param {Object} locator - The locator to verify attributes. + * @param {Object} attProps - The attribute properties and expected values to verify. + * @returns {Boolean} - True if all attribute properties match the expected values, false otherwise. + */ + static async verifyAttributes(locator, attProps) { + this.locator = locator; + let result = true; + await Promise.allSettled( + Object.entries(attProps).map(async ([property, expectedValue]) => { + if (property === 'class' && typeof expectedValue === 'string') { + // If the property is 'class' and the expected value is an string, + // split the string value into individual classes + const classes = expectedValue.split(' '); + try { + await expect(this.locator).toHaveClass(classes.join(' ')); + } catch (error) { + console.error('Attribute class not found:', error); + result = false; + } + } else { + try { + await expect(this.locator).toHaveAttribute(property, expectedValue); + } catch (error) { + console.error(`Attribute ${property} not found:`, error); + result = false; + } + } + }), + ); + return result; + } + + /** + * Verifies that the specified attribute properties of the given locator match the expected values. + * @param {Object} locator - The locator to verify attributes. + * @param {Object} attProps - The attribute properties and expected values to verify. + * @returns {Boolean} - True if all attribute properties match the expected values, false otherwise. + */ + + async verifyAttributes_(locator, attProps) { + this.locator = locator; + let result = true; + await Promise.allSettled( + Object.entries(attProps).map(async ([property, expectedValue]) => { + if (property === 'class' && typeof expectedValue === 'string') { + // If the property is 'class' and the expected value is an string, + // split the string value into individual classes + const classes = expectedValue.split(' '); + try { + await expect(await this.locator).toHaveClass(classes.join(' ')); + } catch (error) { + console.error('Attribute class not found:', error); + result = false; + } + } else { + try { + await expect(await this.locator).toHaveAttribute(property, expectedValue); + } catch (error) { + console.error(`Attribute ${property} not found:`, error); + result = false; + } + } + }), + ); + return result; + } + + /** + * Slow/fast scroll of entire page JS evaluation method, aides with lazy loaded content. + * This wrapper method calls a scroll script in page.evaluate, i.e. page.evaluate(scroll, { dir: 'direction', spd: 'speed' }); + * @param direction string direction you want to scroll on the page + * @param speed string speed you would like to scroll through the page. Options: slow, fast + */ + async scrollPage(direction, speed) { + const scroll = async (args) => { + const { dir, spd } = args; + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scrollHeight = () => document.body.scrollHeight; + const start = dir === 'down' ? 0 : scrollHeight(); + const shouldStop = (position) => (dir === 'down' ? position > scrollHeight() : position < 0); + const increment = dir === 'down' ? 100 : -100; + const delayTime = spd === 'slow' ? 30 : 5; + console.error(start, shouldStop(start), increment); + for (let i = start; !shouldStop(i); i += increment) { + window.scrollTo(0, i); + + await delay(delayTime); + } + }; + + await this.page.evaluate(scroll, { dir: direction, spd: speed }); + } + + /** + * Check if the modal associated with the current locator is within the viewport. + * @param page - calling method page object. + * @returns {Promise} - Resolves to true if the modal is within the viewport, or false. + */ + static async isModalInViewport(page, selector) { + try { + const inViewport = await page.evaluate((sel) => { + const modalDialog = document.querySelector('.dialog-modal'); + if (!modalDialog) { + throw new Error(`Modal element with selector '${sel}' not found.`); + } + const rect = modalDialog.getBoundingClientRect(); + return ( + rect.top >= 0 + && rect.left >= 0 + && rect.bottom + <= (window.innerHeight || document.documentElement.clientHeight) + && rect.right + <= (window.innerWidth || document.documentElement.clientWidth) + ); + }, selector); + + return inViewport; + } catch (error) { + console.error('Error verifying modal veiwport:', error); + return false; + } + } + + /** + * Load test data from yml file or json file in local + * @param {string} filePath + */ + static async loadTestData(dataFilePath) { + return dataFilePath.includes('.yml') ? yaml.load(fs.readFileSync(dataFilePath, 'utf8')) : JSON.parse(fs.readFileSync(dataFilePath, 'utf8')); + } + + /** + * Load test data from remote json file + * @param {string} path + * @param {string} url + */ + static async loadTestDataFromAPI(url, path) { + const context = await request.newContext({ baseURL: url }); + const res = await context.fetch(path); + return res.json(); + } + + /** + * Makes a GET request + * @param {string} url - The URL to make the GET request to. + * @returns {object} The response object. + */ + static async getRequest(url) { + const requestContext = await request.newContext(); + const response = await requestContext.get(url); + return response; + } + + /** + * Enable network logging + * @param {Array} networklogs - An array to store all network logs + */ + async enableNetworkLogging(networklogs) { + await this.page.route('**', (route) => { + const url = route.request().url(); + if (url.includes('sstats.adobe.com/ee/or2/v1/interact') + || url.includes('sstats.adobe.com/ee/or2/v1/collect')) { + networklogs.push(url); + const firstEvent = route.request().postDataJSON().events[0]; + + if (firstEvent.data._adobe_corpnew.digitalData.primaryEvent) { + + networklogs.push(JSON.stringify(firstEvent.data._adobe_corpnew.digitalData.primaryEvent)); + } + + + if (firstEvent.data._adobe_corpnew.digitalData.search) { + + networklogs.push(JSON.stringify(firstEvent.data._adobe_corpnew.digitalData.search)); + } + } + route.continue(); + }); + } + + /** + * Disable network logging + */ + async disableNetworkLogging() { + await this.page.unroute('**'); + } + + /** + * Generates analytic string for a given project. + * @param {string} project - The project identifier, defaulting to 'milo' if not provided. + * @returns {string} - A string formatted as 'gnav||nopzn|nopzn'. + */ + + async getGnavDaalh(project = milo) { + return `gnav|${project}|nopzn|nopzn`; + } + + /** + * Generates analytic string for a given project. + * @param {string} project - The project identifier, defaulting to 'milo' if not provided. + * @param {string} pznExpName - Personalized experience name, which is sliced to its first 15 characters. + * @param {string} pznFileName - Manifest filename, which is sliced to its first 20 characters. + * @returns {string} - A string formatted as 'gnav|||'. + */ + + async getPznGnavDaalh(project = milo, pznExpName, pznFileName) { + const slicedExpName = pznExpName.slice(0, 15); + const slicedFileName = pznFileName.slice(0, 15); + return `gnav|${project}|${slicedExpName}|${slicedFileName}`; + } + + /** + * Generates analytic string for a section based on a given counter value. + * @param {number|string} counter - A counter value used to generate the section identifier. + * @returns {string} - A string formatted as 's'. + */ + + async getSectionDaalh(counter) { + return `s${counter}`; + } + + /** + * Generates personalization analytic string for a given block name and a counter. + * @param {string} blockName - The name of the block, which is sliced to its first 20 characters. + * @param {number|string} counter - A counter value i.e. block number. + * @param {string} pznExpName - Personalized experience name, which is sliced to its first 15 characters. + * @param {string} pznExpName - Manifest filename, which is sliced to its first 20 characters. + * @returns {string} - A string formatted as 'b|||'. + */ + + async getPznBlockDaalh(blockName, counter, pznExpName, pznFileName) { + const slicedBlockName = blockName.slice(0, 20); + const slicedExpName = pznExpName.slice(0, 15); + const slicedFileName = pznFileName.slice(0, 15); + return `b${counter}|${slicedBlockName}|${slicedExpName}|${slicedFileName}`; + } + + /** + * Generates an analytic string for a given block name and a counter. + * @param {string} blockName - The name of the block, which is sliced to its first 20 characters. + * @param {number|string} counter - A counter value, i.e., block number. + * @param {boolean} [pzn=false] - A boolean flag indicating whether to use pzntext. + * @param {string} [pzntext='nopzn'] - The pzntext to use when pzn is true, sliced to its first 15 characters. + * @returns {string} - A formatted string. + */ + + async getBlockDaalh(blockName, counter, pzn = false, pzntext = 'nopzn') { + const slicedBlockName = blockName.slice(0, 20); + const slicedPzntext = pzntext.slice(0, 15); + if (pzn) { + return `b${counter}|${slicedBlockName}|${slicedPzntext}|nopzn`; + } + return `b${counter}|${slicedBlockName}`; + } + + /** + * Generates analytic string for link or button based on link/button text , a counter, and the last header text. + * @param {string} linkText - The text of the link, which is cleaned and sliced to its first 20 characters. + * @param {number|string} counter - A counter value used in the identifier. + * @param {string} lastHeaderText - The last header text, which is cleaned and sliced to its first 20 characters. + * @param {boolean} [pzn=false] - boolean parameter, defaulting to false.(for personalization) + * @returns {string} - A string formatted as '---'. + */ + + async getLinkDaall(linkText, counter, lastHeaderText, pzn = false) { + const cleanAndSliceText = (text) => text + ?.replace(/[^\w\s]+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^_+|_+$/g, '') + .trim() + .slice(0, 20); + const slicedLinkText = cleanAndSliceText(linkText); + const slicedLastHeaderText = cleanAndSliceText(lastHeaderText); + return `${slicedLinkText}-${counter}--${slicedLastHeaderText}`; + } +}; \ No newline at end of file diff --git a/nala/studio/studio.page.js b/nala/studio/studio.page.js new file mode 100644 index 00000000..4f49eb28 --- /dev/null +++ b/nala/studio/studio.page.js @@ -0,0 +1,80 @@ +export default class StudioPage { + constructor(page) { + this.page = page; + + this.quickActions = page.locator('.quick-actions'); + this.recentlyUpdated = page.locator('.recently-updated'); + this.gotoContent = page.locator( + '.quick-action-card[heading="Go to Content"]', + ); + + this.searchInput = page.locator('sp-search input'); + this.searchIcon = page.locator('sp-search sp-icon-magnify'); + this.filter = page.locator('sp-action-button[label="Filter"]'); + this.topFolder = page.locator('sp-picker[label="TopFolder"] > button'); + this.renderView = page.locator('#render'); + this.quickActions = page.locator('.quick-actions'); + this.editorPanel = page.locator('editor-panel'); + this.suggestedCard = page.locator( + 'merch-card[variant="ccd-suggested"]', + ); + this.sliceCard = page.locator('merch-card[variant="ccd-slice"]'); + this.sliceCardWide = page.locator( + 'merch-card[variant="ccd-slice"][size="wide"]', + ); + this.price = page.locator('span[data-template="price"]'); + this.priceStrikethrough = page.locator( + 'span[data-template="strikethrough"]', + ); + this.cardIcon = page.locator('merch-icon'); + this.cardBadge = page.locator('.ccd-slice-badge'); + // Editor panel fields + this.editorTitle = page.locator('#card-title'); + // suggested cards + this.suggestedCard = page.locator( + 'merch-card[variant="ccd-suggested"]', + ); + this.suggestedCardTitle = this.page.locator('h3[slot="heading-xs"]'); + this.suggestedCardEyebrow = page.locator('h4[slot="detail-s"]'); + this.suggestedCardDescription = page + .locator('div[slot="body-xs"] p') + .first(); + this.suggestedCardLegalLink = page.locator('div[slot="body-xs"] p > a'); + this.suggestedCardPrice = page.locator('p[slot="price"]'); + this.suggestedCardCTA = page.locator('div[slot="cta"] > sp-button'); + this.suggestedCardCTALink = page.locator( + 'div[slot="cta"] a[is="checkout-link"]', + ); + // slice cards + this.sliceCard = page.locator('merch-card[variant="ccd-slice"]'); + this.sliceCardWide = page.locator( + 'merch-card[variant="ccd-slice"][size="wide"]', + ); + this.sliceCardImage = page.locator('div[slot="image"] img'); + this.sliceCardDescription = page + .locator('div[slot="body-s"] p > strong') + .first(); + this.sliceCardLegalLink = page.locator('div[slot="body-s"] p > a'); + this.sliceCardCTA = page.locator('div[slot="footer"] > sp-button'); + this.sliceCardCTALink = page.locator( + 'div[slot="footer"] a[is="checkout-link"]', + ); + } + + async getCard(id, cardType) { + const cardVariant = { + suggested: this.suggestedCard, + slice: this.sliceCard, + 'slice-wide': this.sliceCardWide, + }; + + const card = cardVariant[cardType]; + if (!card) { + throw new Error(`Invalid card type: ${cardType}`); + } + + return card.filter({ + has: this.page.locator(`aem-fragment[fragment="${id}"]`), + }); + } +} diff --git a/nala/studio/studio.spec.js b/nala/studio/studio.spec.js new file mode 100644 index 00000000..cb7abf4e --- /dev/null +++ b/nala/studio/studio.spec.js @@ -0,0 +1,52 @@ +export default { + FeatureName: 'M@S Studio', + features: [ + { + tcid: '0', + name: '@studio-load', + path: '/studio.html', + tags: '@mas-studio', + }, + { + tcid: '1', + name: '@studio-direct-search', + path: '/studio.html', + data: { + cardid: '206a8742-0289-4196-92d4-ced99ec4191e', + title: 'Automation Test Card', + eyebrow: 'DO NOT EDIT', + description: 'MAS repo validation card for Nala tests.', + price: 'US$22.99/mo', + strikethroughPrice: 'US$37.99/mo', + cta: 'Buy now', + offerid: '30404A88D89A328584307175B8B27616', + linkText: 'See terms', + linkUrl: '', + }, + browserParams: '#query=', + tags: '@mas-studio', + }, + { + tcid: '2', + name: '@studio-search-field', + path: '/studio.html', + data: { + cardid: '206a8742-0289-4196-92d4-ced99ec4191e', + }, + browserParams: '#path=nala', + tags: '@mas-studio', + }, + { + tcid: '3', + name: '@studio-edit-title', + path: '/studio.html', + data: { + cardid: '206a8742-0289-4196-92d4-ced99ec4191e', + title: 'Automation Test Card', + newTitle: 'Change title', + }, + browserParams: '#query=', + tags: '@mas-studio', + }, + ], +}; diff --git a/nala/studio/studio.test.js b/nala/studio/studio.test.js new file mode 100644 index 00000000..599cdcfa --- /dev/null +++ b/nala/studio/studio.test.js @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; +import StudioSpec from './studio.spec.js'; +import StudioPage from './studio.page.js'; +import ims from '../libs/imslogin.js'; + +const { features } = StudioSpec; +const miloLibs = process.env.MILO_LIBS || ''; + +let studio; + +test.beforeEach(async ({ page, browserName, baseURL }) => { + test.slow(); + if (browserName === 'chromium') { + await page.setExtraHTTPHeaders({ + 'sec-ch-ua': '"Chromium";v="123", "Not:A-Brand";v="8"', + }); + } + studio = new StudioPage(page); + features[0].url = `${baseURL}/studio.html`; + await page.goto(features[0].url); + await page.waitForURL('**/auth.services.adobe.com/en_US/index.html**/'); + await ims.fillOutSignInForm(features[0], page); + await expect(async () => { + const response = await page.request.get(features[0].url); + expect(response.status()).toBe(200); + }).toPass(); + await page.waitForLoadState('domcontentloaded'); +}); + +test.describe('M@S Studio feature test suite', () => { + // @studio-load - Validate studio Welcome page is loaded + test(`${features[0].name},${features[0].tags}`, async ({ + page, + baseURL, + }) => { + const testPage = `${baseURL}${features[0].path}${miloLibs}`; + console.info('[Test Page]: ', testPage); + + await test.step('step-1: Go to MAS Studio test page', async () => { + await page.goto(testPage); + await page.waitForLoadState('domcontentloaded'); + }); + + await test.step('step-2: Validate studio load', async () => { + await expect(await studio.quickActions).toBeVisible(); + // enable the follwoing check once loadiing this section is stable + // await expect(await studio.recentlyUpdated).toBeVisible(); + }); + }); + + // @studio-direct-search - Validate direct search feature in mas studio + test.skip(`${features[1].name},${features[1].tags}`, async ({ + // skip the test until MWPW-165152 is fixed + page, + baseURL, + }) => { + const { data } = features[1]; + const testPage = `${baseURL}${features[1].path}${miloLibs}${features[1].browserParams}${data.cardid}`; + console.info('[Test Page]: ', testPage); + + await test.step('step-1: Go to MAS Studio test page', async () => { + await page.goto(testPage); + await page.waitForLoadState('domcontentloaded'); + }); + + await test.step('step-2: Validate search results', async () => { + await expect(await studio.renderView).toBeVisible(); + + const cards = await studio.renderView.locator('merch-card'); + expect(await cards.count()).toBe(1); + }); + }); + + // @studio-search-field - Validate search field in mas studio + test(`${features[2].name},${features[2].tags}`, async ({ + page, + baseURL, + }) => { + const { data } = features[2]; + const testPage = `${baseURL}${features[2].path}${miloLibs}${features[2].browserParams}`; + console.info('[Test Page]: ', testPage); + + await test.step('step-1: Go to MAS Studio test page', async () => { + await page.goto(testPage); + await page.waitForLoadState('domcontentloaded'); + }); + + // remove this step once MWPW-165149 is fixed + await test.step('step-1a: Go to MAS Studio content test page', async () => { + await expect(await studio.gotoContent).toBeVisible(); + await studio.gotoContent.click(); + await page.waitForLoadState('domcontentloaded'); + }); + + await test.step('step-2: Validate search field rendered', async () => { + await expect(await studio.searchInput).toBeVisible(); + await expect(await studio.searchIcon).toBeVisible(); + await expect(await studio.renderView).toBeVisible(); + const cards = await studio.renderView.locator('merch-card'); + expect(await cards.count()).toBeGreaterThan(1); + }); + + await test.step('step-3: Validate search feature', async () => { + await studio.searchInput.fill(data.cardid); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + expect(await studio.getCard(data.cardid, 'suggested')).toBeVisible; + const searchResult = await studio.renderView.locator('merch-card'); + expect(await searchResult.count()).toBe(1); + }); + }); + + // @studio-edit-title - Validate edit title feature in mas studio + test(`${features[3].name},${features[3].tags}`, async ({ + page, + baseURL, + }) => { + const { data } = features[3]; + // uncomment the following line once MWPW-165149 is fixed and delete the line after + // const testPage = `${baseURL}${features[3].path}${miloLibs}${features[3].browserParams}${data.cardid}`; + const testPage = `${baseURL}${features[3].path}${miloLibs}${'#path=nala'}`; + console.info('[Test Page]: ', testPage); + + await test.step('step-1: Go to MAS Studio test page', async () => { + await page.goto(testPage); + await page.waitForLoadState('domcontentloaded'); + }); + + // remove this step once MWPW-165149 is fixed + await test.step('step-1a: Go to MAS Studio content test page', async () => { + await expect(await studio.gotoContent).toBeVisible(); + await studio.gotoContent.click(); + await page.waitForLoadState('domcontentloaded'); + }); + + // remove this step once MWPW-165152 is fixed + await test.step('step-1b: Search for the card', async () => { + await studio.searchInput.fill(data.cardid); + await page.keyboard.press('Enter'); + await page.waitForTimeout(2000); + expect(await studio.getCard(data.cardid, 'suggested')).toBeVisible; + }); + + await test.step('step-2: Open card editor', async () => { + expect(await studio.getCard(data.cardid, 'suggested')).toBeVisible; + await (await studio.getCard(data.cardid, 'suggested')).dblclick(); + expect(await studio.editorPanel).toBeVisible; + }); + + await test.step('step-3: Edit title field', async () => { + expect(await studio.editorPanel.title).toBeVisible; + await expect( + await studio.editorPanel.locator(studio.editorTitle), + ).toHaveAttribute('value', `${data.title}`); + await studio.editorPanel + .locator(studio.editorTitle) + .locator('input') + .fill(data.newTitle); + }); + + await test.step('step-4: Validate edited title field', async () => { + await expect( + await studio.editorPanel.locator(studio.editorTitle), + ).toHaveAttribute('value', `${data.newTitle}`); + }); + }); +}); diff --git a/nala/utils/base-reporter.js b/nala/utils/base-reporter.js new file mode 100644 index 00000000..ce97b984 --- /dev/null +++ b/nala/utils/base-reporter.js @@ -0,0 +1,221 @@ +/* eslint-disable */ +// Playwright will include ANSI color characters and regex from below +// https://github.com/microsoft/playwright/issues/13522 +// https://github.com/chalk/ansi-regex/blob/main/index.js#L3 + +const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', +].join('|'); + +const ansiRegex = new RegExp(pattern, 'g'); + +// limit failed status +const failedStatus = ['failed', 'flaky', 'timedOut', 'interrupted']; + +function stripAnsi(str) { + if (!str || typeof str !== 'string') return str; + return str.replace(ansiRegex, ''); +} + +export default class BaseReporter { + constructor(options) { + this.options = options; + this.results = []; + this.passedTests = 0; + this.failedTests = 0; + this.skippedTests = 0; + } + + onBegin(config, suite) { + this.config = config; + this.rootSuite = suite; + + } + + async onTestEnd(test, result) { + const { title, retries, _projectId } = test; + const { name, tags, url, browser, env, branch, repo} = this.parseTestTitle(title, _projectId); + const { + status, + duration, + error: { message: errorMessage, value: errorValue, stack: errorStack } = {}, + retry, + } = result; + + if (retry < retries && status === 'failed') { + return; + } + this.results.push({ + title, + name, + tags, + url, + env, + browser, + branch, + repo, + status: failedStatus.includes(status) ? 'failed' : status, + errorMessage: stripAnsi(errorMessage), + errorValue, + errorStack: stripAnsi(errorStack), + stdout: test.stdout, + stderr: test.stderr, + duration, + retry, + }); + if (status === 'passed') { + this.passedTests++; + } else if (failedStatus.includes(status)) { + this.failedTests++; + } else if (status === 'skipped') { + this.skippedTests++; + } + } + + async onEnd() { + //this.printPersistingOption(); + //await this.persistData(); + const summary = this.printResultSummary(); + const resultSummary = { summary }; + + if (process.env.SLACK_WH) { + try { + console.log('----success to publish result to slack channel----'); +// await sendSlackMessage(process.env.SLACK_WH, resultSummary); + } catch (error){ + console.log('----Failed to publish result to slack channel----'); + } + } + } + + printResultSummary() { + const totalTests = this.results.length; + const passPercentage = ((this.passedTests / totalTests) * 100).toFixed(2); + const failPercentage = ((this.failedTests / totalTests) * 100).toFixed(2); + const miloLibs = process.env.MILO_LIBS || ''; + const prBranchUrl = process.env.PR_BRANCH_LIVE_URL ? (process.env.PR_BRANCH_LIVE_URL + miloLibs) : undefined; + const projectBaseUrl = this.config.projects[0].use.baseURL; + const envURL = prBranchUrl ? prBranchUrl : projectBaseUrl; + + let exeEnv = 'Local Environment'; + let runUrl = 'Local Environment'; + let runName = 'Nala Local Run'; + + if (process.env.GITHUB_ACTIONS === 'true') { + exeEnv = 'GitHub Actions Environment'; + const repo = process.env.GITHUB_REPOSITORY; + const runId = process.env.GITHUB_RUN_ID; + const prNumber = process.env.GITHUB_REF.split('/')[2]; + runUrl = `https://github.com/${repo}/actions/runs/${runId}`; + runName = `${process.env.WORKFLOW_NAME ? (process.env.WORKFLOW_NAME || 'Nala Daily Run') : 'Nala PR Run'} (${prNumber})`; + } else if (process.env.CIRCLECI) { + exeEnv = 'CircleCI Environment'; + const workflowId = process.env.CIRCLE_WORKFLOW_ID; + const jobNumber = process.env.CIRCLE_BUILD_NUM; + runUrl = `https://app.circle.ci.adobe.com/pipelines/github/wcms/nala/${jobNumber}/workflows/${workflowId}/jobs/${jobNumber}`; + runName = 'Nala CircleCI/Stage Run'; + } + + const summary = ` + \x1b[1m\x1b[34m---------Nala Test Run Summary------------\x1b[0m + \x1b[1m\x1b[33m# Total Test executed:\x1b[0m \x1b[32m${totalTests}\x1b[0m + \x1b[1m\x1b[33m# Test Pass :\x1b[0m \x1b[32m${this.passedTests} (${passPercentage}%)\x1b[0m + \x1b[1m\x1b[33m# Test Fail :\x1b[0m \x1b[31m${this.failedTests} (${failPercentage}%)\x1b[0m + \x1b[1m\x1b[33m# Test Skipped :\x1b[0m \x1b[32m${this.skippedTests}\x1b[0m + \x1b[1m\x1b[33m** Application URL :\x1b[0m \x1b[32m${envURL}\x1b[0m + \x1b[1m\x1b[33m** Executed on :\x1b[0m \x1b[32m${exeEnv}\x1b[0m + \x1b[1m\x1b[33m** Execution details:\x1b[0m \x1b[32m${runUrl}\x1b[0m + \x1b[1m\x1b[33m** Workflow name :\x1b[0m \x1b[32m${runName}\x1b[0m`; + + console.log(summary); + + if (this.failedTests > 0) { + console.log('-------- Test Failures --------'); + this.results + .filter((result) => result.status === 'failed') + .forEach((failedTest) => { + console.log(`Test: ${failedTest.title.split('@')[1]}`); + console.log(`Error Message: ${failedTest.errorMessage}`); + console.log(`Error Stack: ${failedTest.errorStack}`); + console.log('-------------------------'); + }); + } + return summary; + } + + /** + This method takes test title and projectId strings and then processes it . + @param {string, string} str - The input string to be processed + @returns {'name', 'tags', 'url', 'browser', 'env', 'branch' and 'repo'} + */ + parseTestTitle(title, projectId) { + let env = 'live'; + let browser = 'chrome'; + let branch; + let repo; + let url; + + const titleParts = title.split('@'); + const name = titleParts[1].trim(); + const tags = titleParts.slice(2).map(tag => tag.trim()); + + const projectConfig = this.config.projects.find(project => project.name === projectId); + + // Get baseURL from project config + if (projectConfig?.use?.baseURL) { + ({ baseURL: url, defaultBrowserType: browser } = projectConfig.use); + } else if (this.config.baseURL) { + url = this.config.baseURL; + } + // Get environment from baseURL + if (url.includes('prod')) { + env = 'prod'; + } else if (url.includes('stage')) { + env = 'stage'; + } + // Get branch and repo from baseURL + if (url.includes('localhost')) { + branch = 'local'; + repo = 'local'; + } else { + const urlParts = url.split('/'); + const branchAndRepo = urlParts[urlParts.length - 1]; + [branch, repo] = branchAndRepo.split('--'); + } + + return { name, tags, url, browser, env, branch, repo}; + } + + + async persistData() {} + + printPersistingOption() { + if (this.options?.persist) { + console.log( + `Persisting results using ${this.options.persist?.type} to ${this.options.persist?.path}`, + ); + } else { + console.log('Not persisting data'); + } + //this.branch1 = process.env.GITHUB_REF_NAME ?? 'local'; + this.branch = process.env.LOCAL_TEST_LIVE_URL; + } + + getPersistedDataObject() { + const gitBranch = process.env.GITHUB_REF_NAME ?? 'local'; + + // strip out git owner since it can usually be too long to show on the ui + const [, gitRepo] = /[A-Za-z0-9_.-]+\/([A-Za-z0-9_.-]+)/.exec( + process.env.GITHUB_REPOSITORY, + ) ?? [null, 'local']; + + const currTime = new Date(); + return { + gitBranch, + gitRepo, + results: this.results, + timestamp: currTime, + }; + } +} diff --git a/nala/utils/global.setup.js b/nala/utils/global.setup.js new file mode 100644 index 00000000..bf4c005d --- /dev/null +++ b/nala/utils/global.setup.js @@ -0,0 +1,136 @@ +/* eslint-disable */ +import { exit } from 'process'; +import { execSync } from 'child_process'; +import { isBranchURLValid } from '../libs/baseurl.js'; + +const MAIN_BRANCH_LIVE_URL = 'https://main--mas--adobecom.aem.live'; +const STAGE_URL = 'https://mas.stage.adobe.com'; + +async function getGitHubPRBranchLiveUrl() { + // get the pr number + const prReference = process.env.GITHUB_REF; + const prNumber = prReference.split('/')[2]; + + // get the pr branch name + const branch = process.env.GITHUB_HEAD_REF; + const prBranch = branch.replace(/\//g, '-'); + + // get the org and repo + const repository = process.env.GITHUB_REPOSITORY; + const repoParts = repository.split('/'); + const toRepoOrg = repoParts[0]; + const toRepoName = repoParts[1]; + + // Get the org and repo from the environment variables + const prFromOrg = process.env.prOrg; + const prFromRepoName = process.env.prRepo; + + const prBranchLiveUrl = `https://${prBranch}--${prFromRepoName}--${prFromOrg}.aem.live`; + + try { + if (await isBranchURLValid(prBranchLiveUrl)) { + process.env.PR_BRANCH_LIVE_URL = prBranchLiveUrl; + } + console.info('PR Repository : ', repository); + console.info('PR TO ORG : ', toRepoOrg); + console.info('PR TO REPO : ', toRepoName); + console.info('PR From ORG : ', prFromOrg); + console.info('PR From REPO : ', prFromRepoName); + console.info('PR Branch : ', branch); + console.info('PR Branch(U) : ', prBranch); + console.info('PR Number : ', prNumber); + console.info('PR From Branch live url : ', prBranchLiveUrl); + } catch (err) { + console.error(`Error => Error in setting PR Branch test URL : ${prBranchLiveUrl}`); + console.info(`Note: PR branch test url ${prBranchLiveUrl} is not valid, Exiting test execution.`); + process.exit(1); + } +} + +async function getGitHubMiloLibsBranchLiveUrl() { + const repository = process.env.GITHUB_REPOSITORY; + + let prBranchLiveUrl; + let miloLibs; + + prBranchLiveUrl = process.env.PR_BRANCH_MILOLIBS_LIVE_URL; + miloLibs = process.env.MILO_LIBS; + + try { + if (await isBranchURLValid(prBranchLiveUrl)) { + process.env.PR_BRANCH_LIVE_URL = prBranchLiveUrl; + } + console.info('PR Repository : ', repository); + console.info('PR Branch live url : ', prBranchLiveUrl); + console.info('Milo Libs : ', miloLibs); + } catch (err) { + console.error(`Error => Error in setting PR Branch test URL : ${prBranchLiveUrl}`); + console.info(`Note: PR branch test url ${prBranchLiveUrl} is not valid, Exiting test execution.`); + process.exit(1); + } +} + +async function getCircleCIBranchLiveUrl() { + const stageUrl = STAGE_URL; + + try { + if (await isBranchURLValid(stageUrl)) { + process.env.PR_BRANCH_LIVE_URL = stageUrl; + } + console.info('Stage Branch Live URL : ', stageUrl); + } catch (err) { + console.error('Error => Error in setting Stage Branch test URL : ', stageUrl); + console.info('Note: Stage branch test url is not valid, Exiting test execution.'); + process.exit(1); + } +} + +async function getLocalBranchLiveUrl() { + let localTestLiveUrl + try { + const localGitRootDir = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + + if (localGitRootDir) { + const gitRemoteOriginUrl = execSync('git config --get remote.origin.url', { cwd: localGitRootDir, encoding: 'utf-8' }).trim(); + const match = gitRemoteOriginUrl.match(/github\.com\/(.*?)\/(.*?)\.git/); + + if (match) { + const [localOrg, localRepo] = match.slice(1, 3); + const localBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: localGitRootDir, encoding: 'utf-8' }).trim(); + localTestLiveUrl = process.env.LOCAL_TEST_LIVE_URL || MAIN_BRANCH_LIVE_URL + if (await isBranchURLValid(localTestLiveUrl)) { + console.info('Git ORG : ', localOrg); + console.info('Git REPO : ', localRepo); + console.info('Local Branch : ', localBranch); + console.info('Local Test Live URL : ', localTestLiveUrl); + } + } + } + } catch (error) { + console.error(`Error => Error in setting local test URL : ${localTestLiveUrl}\n`); + console.info(`Note: Local or branch test url is not valid, Exiting test execution.\n`); + process.exit(1); + } +} + +async function globalSetup() { + console.info('---- Executing Nala Global setup ----\n'); + + if (process.env.GITHUB_ACTIONS === 'true') { + console.info('---- Running Nala Tests in the GitHub environment ----\n'); + + if (process.env.MILO_LIBS_RUN === 'true') { + await getGitHubMiloLibsBranchLiveUrl(); + } else { + await getGitHubPRBranchLiveUrl(); + } + } else if (process.env.CIRCLECI) { + console.info('---- Running Nala Tests in the CircleCI environment ----\n'); + await getCircleCIBranchLiveUrl(); + } else { + console.info('---- Running Nala Tests in the Local environment ----\n'); + await getLocalBranchLiveUrl(); + } +} + +export default globalSetup; diff --git a/nala/utils/nala.run.js b/nala/utils/nala.run.js new file mode 100644 index 00000000..c289d363 --- /dev/null +++ b/nala/utils/nala.run.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawn } from 'child_process'; + +function displayHelp() { + console.log(` + +\x1b[1m\x1b[37m## Nala command:\x1b[0m \x1b[1m\x1b[32mnpm run nala [env] [options]\x1b[0m + +\x1b[1m1] Env:\x1b[0m [\x1b[32mlocal\x1b[0m | \x1b[32mlibs\x1b[0m | \x1b[32mbranch\x1b[0m | \x1b[32mstage\x1b[0m | \x1b[32metc\x1b[0m ] \x1b[3mdefault: local\x1b[0m + +\x1b[1m2] Options:\x1b[0m + + \x1b[33m* browser=\x1b[0m Browser to run the test in + \x1b[33m* device=\x1b[0m Device type to run the test on + \x1b[33m* test=<.test.js>\x1b[0m Specific test file to run (runs all tests in the file) + \x1b[33m* -g, --g=<@tag>\x1b[0m Annotation Tag to filter and run tests by annotation (e.g., @test1, @accordion, @marquee) + \x1b[33m* mode=\x1b[0m Mode (default: headless) + \x1b[33m* config=\x1b[0m Custom configuration file to use (default: Playwright's default) + \x1b[33m* project=\x1b[0m Project configuration (default: mas-live-chromium) + \x1b[33m* milolibs=\x1b[0m Milo library environment (default: none) + \x1b[33m* owner=\x1b[0m repo owner (default owner = adobecom) + +\x1b[1mExamples:\x1b[0m + | \x1b[36mCommand\x1b[0m | \x1b[36mDescription\x1b[0m | + |--------------------------------------------------------|------------------------------------------------------------------------------------| + | npm run nala local | Runs all nala tests on local environment on chrome browser | + | npm run nala local accordion.test.js | Runs only accordion tests on local environment on chrome browser | + | npm run nala local @accordion | Runs only accordion annotated/tagged tests on local environment on chrome browser | + | npm run nala local @accordion browser=firefox | Runs only accordion annotated/tagged tests on local environment on firefox browser | + | npm run nala local mode=ui | Runs all nala tests on local environment in UI mode on chrome browser | + | npm run nala local -g=@accordion | Runs tests annotated with tag i.e @accordion on local env on chrome browser | + | npm run nala local -g=@accordion browser=firefox | Runs tests annotated with tag i.e @accordion on local env on Firefox browser | + | npm run nala owner='' | Runs all nala tests on the specified feature branch for the given repo owner | + +\x1b[1mDebugging:\x1b[0m +----------- + | \x1b[36mCommand\x1b[0m | \x1b[36mDescription\x1b[0m | + |--------------------------------------------------------|------------------------------------------------------------------------------------| + | npm run nala local @test1 mode=debug | Runs @test1 on local environment in debug mode | + +`); +} + +function parseArgs(args) { + const defaultParams = { + env: 'local', + browser: 'chromium', + device: 'desktop', + test: '', + tag: '', + mode: 'headless', + config: '', + project: '', + milolibs: '', + repo: 'mas', + owner: 'adobecom', + }; + + const parsedParams = { ...defaultParams }; + + args.forEach((arg) => { + if (arg.includes('=')) { + const [key, value] = arg.split('='); + parsedParams[key] = value; + } else if (arg.startsWith('-g') || arg.startsWith('--g')) { + const value = arg.includes('=') + ? arg.split('=')[1] + : args[args.indexOf(arg) + 1]; + parsedParams.tag = value; + } else if (arg.startsWith('@')) { + parsedParams.tag += parsedParams.tag + ? ` ${arg.substring(1)}` + : arg.substring(1); + } else if (arg.endsWith('.test.js')) { + parsedParams.test = arg; + } else if (arg.endsWith('.config.js')) { + parsedParams.config = arg; + } else if (['ui', 'debug', 'headless', 'headed'].includes(arg)) { + parsedParams.mode = arg; + } else if (arg.startsWith('repo=')) { + const repo = arg.split('=')[1]; + parsedParams.repo = repo || 'mas'; + } else if (arg.startsWith('owner=')) { + const owner = arg.split('=')[1]; + parsedParams.owner = owner || 'adobecom'; + } else { + parsedParams.env = arg; + } + }); + + // Set the project if not provided + if (!parsedParams.project) { + parsedParams.project = `mas-live-${parsedParams.browser}`; + } + + return parsedParams; +} + +function getLocalTestLiveUrl(env, milolibs, repo = 'mas', owner = 'adobecom') { + if (milolibs) { + process.env.MILO_LIBS = `?milolibs=${milolibs}`; + if (env === 'local') { + return 'http://127.0.0.1:3000'; + } + if (env === 'libs') { + return 'http://127.0.0.1:6456'; + } + return `https://${env}--${repo}--${owner}.aem.live`; + } + if (env === 'local') { + return 'http://127.0.0.1:3000'; + } + if (env === 'libs') { + return 'http://127.0.0.1:6456'; + } + return `https://${env}--${repo}--${owner}.aem.live`; +} + +function buildPlaywrightCommand(parsedParams, localTestLiveUrl) { + const { browser, device, test, tag, mode, config, project } = parsedParams; + + const envVariables = { + ...process.env, + BROWSER: browser, + DEVICE: device, + HEADLESS: mode === 'headless' || mode === 'headed' ? 'true' : 'false', + LOCAL_TEST_LIVE_URL: localTestLiveUrl, + }; + + const command = 'npx playwright test'; + const options = []; + + if (test) { + options.push(test); + } + + options.push(`--project=${project}`); + options.push('--grep-invert nopr'); + + if (tag) { + options.push(`-g "${tag.replace(/,/g, ' ')}"`); + } + + if (mode === 'ui' || mode === 'headed') { + options.push('--headed'); + } else if (mode === 'debug') { + options.push('--debug'); + } + + if (config) { + options.push(`--config=${config}`); + } + + return { finalCommand: `${command} ${options.join(' ')}`, envVariables }; +} + +function runNalaTest() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes('help')) { + displayHelp(); + process.exit(0); + } + + const parsedParams = parseArgs(args); + const localTestLiveUrl = getLocalTestLiveUrl( + parsedParams.env, + parsedParams.milolibs, + parsedParams.repo, + parsedParams.owner, + ); + const { finalCommand, envVariables } = buildPlaywrightCommand( + parsedParams, + localTestLiveUrl, + ); + + console.log(`\n Executing nala run command: ${finalCommand}`); + console.log(`\n Using URL: ${localTestLiveUrl}\n`); + console.log( + `\n\x1b[1m\x1b[33mExecuting nala run command:\x1b[0m \x1b[32m${finalCommand}\x1b[0m\n\x1b[1m\x1b[33mUsing URL:\x1b[0m \x1b[32m${localTestLiveUrl}\x1b[0m\n`, + ); + + const testProcess = spawn(finalCommand, { + stdio: 'inherit', + shell: true, + env: envVariables, + }); + + testProcess.on('close', (code) => { + console.log(`Nala tests exited with code ${code}`); + process.exit(code); + }); +} + +if (import.meta.url === new URL(import.meta.url).href) { + runNalaTest(); +} + +export { + displayHelp, + parseArgs, + getLocalTestLiveUrl, + buildPlaywrightCommand, + runNalaTest, +}; diff --git a/nala/utils/pr.run.sh b/nala/utils/pr.run.sh new file mode 100644 index 00000000..6796d623 --- /dev/null +++ b/nala/utils/pr.run.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +TAGS="" +REPORTER="" +EXCLUDE_TAGS="--grep-invert nopr" +EXIT_STATUS=0 + +echo "GITHUB_REF: $GITHUB_REF" +echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" + +if [[ "$GITHUB_REF" == refs/pull/* ]]; then + # extract PR number and branch name + PR_NUMBER=$(echo "$GITHUB_REF" | awk -F'/' '{print $3}') + FEATURE_BRANCH="$GITHUB_HEAD_REF" +elif [[ "$GITHUB_REF" == refs/heads/* ]]; then + # extract branch name from GITHUB_REF + FEATURE_BRANCH=$(echo "$GITHUB_REF" | awk -F'/' '{print $3}') +else + echo "Unknown reference format" +fi + +# Replace "/" characters in the feature branch name with "-" +FEATURE_BRANCH=$(echo "$FEATURE_BRANCH" | sed 's/\//-/g') + +echo "PR Number: ${PR_NUMBER:-"N/A"}" +echo "Feature Branch Name: $FEATURE_BRANCH" + +repository=${GITHUB_REPOSITORY} +repoParts=(${repository//\// }) +toRepoOrg=${repoParts[0]} +toRepoName=${repoParts[1]} + +prRepo=${prRepo:-$toRepoName} +prOrg=${prOrg:-$toRepoOrg} + +# TODO ADD HLX5 SUPPORT +PR_BRANCH_LIVE_URL_GH="https://$FEATURE_BRANCH--$prRepo--$prOrg.aem.live" + +# set pr branch url as env +export PR_BRANCH_LIVE_URL_GH +export PR_NUMBER + +echo "PR Branch live URL: $PR_BRANCH_LIVE_URL_GH" + + +# Convert GitHub Tag(@) labels that can be grepped +for label in ${labels}; do + if [[ "$label" = \@* ]]; then + label="${label:1}" + TAGS+="|$label" + fi +done + +# Remove the first pipe from tags if tags are not empty +[[ ! -z "$TAGS" ]] && TAGS="${TAGS:1}" && TAGS="-g $TAGS" + +# Retrieve GitHub reporter parameter if not empty +# Otherwise, use reporter settings in playwright.config.js +REPORTER=$reporter +[[ ! -z "$REPORTER" ]] && REPORTER="--reporter $REPORTER" + +echo "Running Nala on branch: $FEATURE_BRANCH " +echo "Tags : ${TAGS:-"No @tags or annotations on this PR"}" +echo "Run Command : npx playwright test ${TAGS} ${EXCLUDE_TAGS} ${REPORTER}" +echo -e "\n" +echo "*******************************" + +# Navigate to the GitHub Action path and install dependencies +cd "$GITHUB_ACTION_PATH" || exit +npm ci +npx playwright install --with-deps + +# Run Playwright tests on the specific projects using root-level playwright.config.js +# This will be changed later +echo "*** Running tests on specific projects ***" +npx playwright test --config=./playwright.config.js ${TAGS} ${EXCLUDE_TAGS} --project=mas-live-chromium ${REPORTER} || EXIT_STATUS=$? + +# Check if tests passed or failed +if [ $EXIT_STATUS -ne 0 ]; then + echo "Some tests failed. Exiting with error." + exit $EXIT_STATUS +else + echo "All tests passed successfully." +fi diff --git a/package-lock.json b/package-lock.json index 2e121e68..f3e3e27f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ ], "dependencies": { "@adobecom/milo": "github:adobecom/milo#stage", + "@playwright/test": "^1.42.1", "@spectrum-css/button": "^13.5.0", "@spectrum-css/link": "^5.2.0", "@spectrum-css/page": "^8.2.0", @@ -53,6 +54,7 @@ "@spectrum-web-components/tooltip": "^0.47.2", "@spectrum-web-components/top-nav": "^0.47.2", "@spectrum-web-components/tray": "^0.47.2", + "axios": "^1.7.4", "prosemirror-commands": "^1.6.1", "prosemirror-example-setup": "^1.2.3", "prosemirror-keymap": "^1.2.2", @@ -564,6 +566,20 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@preact/signals": { "version": "1.0.4", "license": "MIT", @@ -2368,6 +2384,11 @@ "lodash": "^4.17.14" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/axe-core": { "version": "4.10.2", "dev": true, @@ -2376,6 +2397,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -2750,6 +2781,17 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/command-line-args": { "version": "5.2.1", "dev": true, @@ -2963,6 +3005,14 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "dev": true, @@ -3640,6 +3690,38 @@ "version": "5.2.1", "license": "W3C" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fresh": { "version": "0.5.2", "dev": true, @@ -3658,6 +3740,19 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "dev": true, @@ -4786,7 +4881,6 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4794,7 +4888,6 @@ }, "node_modules/mime-types": { "version": "2.1.35", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5303,6 +5396,34 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/portfinder": { "version": "1.0.32", "dev": true, @@ -5878,7 +5999,6 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "dev": true, "license": "MIT" }, "node_modules/prr": { diff --git a/package.json b/package.json index 53ae9961..9e87c5a4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "build": "npm run build --workspaces", "studio": "cd studio && npm run proxy & aem up", "studio:qa": "cd studio && npm run proxy:qa & aem up", - "gallery": "cd studio && npm run proxy:publish & aem up" + "gallery": "cd studio && npm run proxy:publish & aem up", + "nala": "node nala/utils/nala.run.js" }, "repository": { "type": "git", @@ -21,6 +22,7 @@ }, "dependencies": { "@adobecom/milo": "github:adobecom/milo#stage", + "axios": "^1.7.4", "@spectrum-css/button": "^13.5.0", "@spectrum-css/link": "^5.2.0", "@spectrum-css/page": "^8.2.0", @@ -62,6 +64,7 @@ "@spectrum-web-components/tooltip": "^0.47.2", "@spectrum-web-components/top-nav": "^0.47.2", "@spectrum-web-components/tray": "^0.47.2", + "@playwright/test": "^1.42.1", "prosemirror-commands": "^1.6.1", "prosemirror-example-setup": "^1.2.3", "prosemirror-keymap": "^1.2.2", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..04b96b9d --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,66 @@ +import { devices } from '@playwright/test'; + +// const envs = require('./envs/envs.js'); + +/** + * @see https://playwright.dev/docs/test-configuration + * @type {import('@playwright/test').PlaywrightTestConfig} + */ + +const config = { + testDir: './nala', + outputDir: './test-results', + globalSetup: './nala/utils/global.setup.js', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + testMatch: '**/*.test.js', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 4 : 3, + /* Reporter to use. */ + reporter: process.env.CI + ? [['github'], ['list'], ['./nala/utils/base-reporter.js']] + : [ + ['html', { outputFolder: 'test-html-results' }], + ['list'], + ['./nala/utils/base-reporter.js'], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 60000, + + trace: 'on-first-retry', + screenshot: 'only-on-failure', + baseURL: + process.env.PR_BRANCH_LIVE_URL || + process.env.LOCAL_TEST_LIVE_URL || + 'https://main--mas--adobecom.aem.live', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'mas-live-chromium', + use: { ...devices['Desktop Chrome'] }, + bypassCSP: true, + launchOptions: { + args: ['--disable-web-security', '--disable-gpu'], + }, + }, + ], +}; + +export default config;