Skip to content
This repository was archived by the owner on Apr 27, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 69 additions & 13 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
module.exports = {
root: true,
extends: ['airbnb-base', 'plugin:react-hooks/recommended', 'plugin:compat/recommended', 'plugin:ecmalist/recommended'],
extends: [
'airbnb-base',
'plugin:react-hooks/recommended',
'plugin:compat/recommended',
'plugin:ecmalist/recommended',
],
settings: { es: { aggressive: true } },
env: { browser: true, mocha: true },
env: {
browser: true,
mocha: true,
},
parser: '@babel/eslint-parser',
parserOptions: {
allowImportExportEverywhere: true,
Expand All @@ -11,11 +19,20 @@ module.exports = {
},
rules: {
'chai-friendly/no-unused-expressions': 2,
'import/extensions': ['error', { js: 'always' }],
'import/extensions': [
'error',
{ js: 'always' },
],
'import/no-cycle': 0,
'linebreak-style': ['error', 'unix'],
'linebreak-style': [
'error',
'unix',
],
'no-await-in-loop': 0,
'no-param-reassign': [2, { props: false }],
'no-param-reassign': [
2,
{ props: false },
],
'no-restricted-syntax': [
'error',
{
Expand All @@ -31,20 +48,59 @@ module.exports = {
message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
},
],
'no-return-assign': ['error', 'except-parens'],
'no-return-assign': [
'error',
'except-parens',
],
'no-unused-expressions': 0,
'object-curly-newline': ['error', {
ObjectExpression: { multiline: true, minProperties: 6 },
ObjectPattern: { multiline: true, minProperties: 6 },
ImportDeclaration: { multiline: true, minProperties: 6 },
ExportDeclaration: { multiline: true, minProperties: 6 },
}],
'object-curly-newline': [
'error',
{
ObjectExpression: {
multiline: true,
minProperties: 6,
},
ObjectPattern: {
multiline: true,
minProperties: 6,
},
ImportDeclaration: {
multiline: true,
minProperties: 6,
},
ExportDeclaration: {
multiline: true,
minProperties: 6,
},
},
],
},
overrides: [
{
files: ['test/**/*.js'],
files: [
'test/**/*.js',
],
rules: { 'no-console': 0 },
},
{
files: [
'nala/**/*.js',
'nala/**/*.test.js',
],
rules: {
'no-console': 0,
'import/no-extraneous-dependencies': 0,
'max-len': 0,
'chai-friendly/no-unused-expressions': 0,
'no-plusplus': 0,
'global-require': 0,
'object-curly-newline': 'off',
'no-unused-vars': 'off',
'arrow-parens': 'off',
'compat/compat': 'off',
'one-var-declaration-per-line': 'off',
},
},
],
ignorePatterns: [
'/libs/deps/*',
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/nala-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Run Nala Tests

on:
push:
branches:
- stage
- main
pull_request:
branches:
- stage
- main
types: [opened, synchronize, reopened]

workflow_dispatch:

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@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 2

- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e
with:
node-version: ${{ matrix.node-version }}

- name: Set execute permission for nalarun.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 }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ CODEOWNERS
#Test files
/**/test-files/empty.*
/**/test-files/bad.*

test-html-results/
test-results/
test-a11y-results/
6 changes: 6 additions & 0 deletions nala/blocks/demo/demo.page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default class DemoBlock {
constructor(page) {
this.page = page;
this.header = this.page.locator('h1');
}
}
15 changes: 15 additions & 0 deletions nala/blocks/demo/demo.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module.exports = {
FeatureName: 'Demo Block',
features: [
{
tcid: '0',
name: '@demo-basic',
// ✅ Default path to let you run the demo test after onboarding
path: '/drafts/nala/demo/demo-page',
// 💡 Replace with your project-specific test page after onboarding
// Example: path: '/drafts/nala/blocks/accordion/accordion-page',
data: { headerText: 'Nala Demo Test' },
tags: '@demo @smoke',
},
],
};
20 changes: 20 additions & 0 deletions nala/blocks/demo/demo.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test';
import DemoBlock from './demo.page.js';
import { features } from './demo.spec.js';

let demoBlock;

test.describe('Demo Block Test Suite', () => {
test.beforeEach(async ({ page }) => {
demoBlock = new DemoBlock(page);
});

test(`${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => {
const { path, data } = features[0];
const finalUrl = path.startsWith('http') ? path : `${baseURL}${path}`;
console.info(`[Demo Test] Navigating to: ${finalUrl}`);
await page.goto(finalUrl);
await expect(demoBlock.header).toBeVisible();
await expect(demoBlock.header).toContainText(data.headerText);
});
});
132 changes: 132 additions & 0 deletions nala/libs/accessibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-disable import/prefer-default-export */

import { test } from '@playwright/test';
import { AccessibilityError } from './customerrors.js';

const AxeBuilder = require('@axe-core/playwright').default;

/**
* Run accessibility test to meet legal compliance (WCAG 2.0/2.1 A & AA)
* @param {Object} page - The page object.
* @param {string} [testScope='body'] - Optional scope for the accessibility test. Default is the entire page ('body').
* @param {string[]} [includeTags=['wcag2a', 'wcag2aa']] - Optional tags to include in the accessibility test. Default is WCAG 2.0/2.1 A & AA.
* @param {number} [maxViolations=0] - Optional maximum number of allowed violations before the test fails. Default is 0 (any violation fails the test).
* @param {boolean} [skipA11yTest=false] - If true, the test step is logged and skipped.
*/
async function runAccessibilityTest({
page,
testScope = 'body',
includeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
maxViolations = 0,
skipA11yTest = false,
} = {}) {
if (skipA11yTest) {
console.log(
`[Skipping]: Accessibility test step skipped for this component ${testScope}`,
);
return;
}

let testName;
try {
// Retrive the test title (name)
testName = test.info().title.includes(',')
? test.info().title.split(',')[0]
: test.info().title;

let scopeDescription = 'entire page';
let testElement = testScope;

let violationsDetails = '';

// Handle a case where testScope is a string or locator from POM
if (typeof testScope === 'string') {
if (testScope === 'body') {
testElement = 'body';
} else {
scopeDescription = `section: ${testScope}`;
}
} else if (
typeof testScope === 'object'
&& testScope.constructor.name === 'Locator'
) {
const eleHandle = await testScope.elementHandle();
if (!eleHandle) {
throw new AccessibilityError('Element not found for the given locator');
}
testElement = eleHandle;
scopeDescription = `the provided locator: ${testScope}`;
} else {
scopeDescription = testScope === 'body' ? 'the entire page' : `section: ${testScope}`;
}

console.log('Scope description:', scopeDescription);
// Run the Axe accessibility test on the given scope and tags
const axe = await new AxeBuilder({ page })
.withTags(includeTags)
.include(testElement)
.analyze();

const enhancedAxeResults = {
testName,
testScope:
testScope === 'body' ? 'Entire Page' : `Specific Section: ${testScope}`,
...axe,
};

const violationCount = enhancedAxeResults.violations.length;

if (violationCount > maxViolations) {
// Accessibility violations details
violationsDetails += '\n========== Accessibility Test ==========\n';
violationsDetails += `[Test Name ]: ${testName}\n`;
violationsDetails += `[Test Page URL]: ${page.url()}\n`;
violationsDetails += `[Accessibility]: Running accessibility test on ${scopeDescription}\n`;

enhancedAxeResults.violations.forEach((violation, index) => {
violationsDetails += `[Result ]: Accessibility test found ${violationCount} accessibility violation(s) for ${testName}\n`;
violationsDetails += '[Violation Details]:\n';
violationsDetails += `\n${index + 1}. Violation: ${
violation.description
}\n`;
violationsDetails += ` - Axe Rule ID: ${violation.id}\n`;
violationsDetails += ` - Severity: ${violation.impact}\n`;

const wcagTags = Array.isArray(violation.tags)
? violation.tags.join(', ')
: 'N/A';
violationsDetails += ` - WCAG Tags: ${wcagTags}\n`;
violationsDetails += ' - Nodes affected:\n';

violation.nodes.forEach((node, nodeIndex) => {
violationsDetails += ` ${nodeIndex + 1}. ${node.html}\n`;
});
violationsDetails += ` - Fix: ${violation.helpUrl}\n`;
});

// attached accessibility voilations details to test results
await test.info().attach('Accessibility Test Results', {
body: JSON.stringify(enhancedAxeResults, null, 2),
contentType: 'application/json',
});

throw new AccessibilityError(
`\nAccessibility test failed : ${violationCount} violation(s) found. \n ${violationsDetails}`,
);
} else if (violationCount > 0) {
console.info(
`[Accessibility Test]: Found ${violationCount} violation(s) for ${testName}, but under the threshold of ${maxViolations}.`,
);
} else {
console.info(
`[Accessibility Test]: No accessibility violations found for ${testName}.`,
);
}
} catch (err) {
throw new AccessibilityError(
`[Accessibility Test failed for ${testName}].\n ${err.message}`,
);
}
}

export { runAccessibilityTest };
17 changes: 17 additions & 0 deletions nala/libs/baseurl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-disable import/no-extraneous-dependencies, import/prefer-default-export, max-len, no-console */
import { head } from 'axios';

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;
}
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 (${error.message})`);
return false;
}
}
9 changes: 9 additions & 0 deletions nala/libs/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// CommonJS Config (.js with module.exports)
const PROJECT = 'dc';
const ORG = 'adobecom';
const BRANCHES = { main: 'main', stage: 'stage' };
const MAIN_BRANCH_LIVE_URL = `https://${BRANCHES.main}--${PROJECT}--${ORG}.aem.live`;
const STAGE_BRANCH_URL = `https://${BRANCHES.stage}--${PROJECT}--${ORG}.aem.live`;
const BASE_URLS = { local: 'http://localhost:3000', stage: STAGE_BRANCH_URL, main: MAIN_BRANCH_LIVE_URL };

module.exports = { PROJECT, ORG, BRANCHES, MAIN_BRANCH_LIVE_URL, STAGE_BRANCH_URL, BASE_URLS };
Loading
Loading