Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/android-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Read Appium version from package.json
id: appium-version
env:
DEFAULT_APPIUM_VERSION: ${{ env.DEFAULT_APPIUM_VERSION }}
GITHUB_WORKSPACE: ${{ github.workspace }}
run: |
echo "Reading Appium version from app/package.json"
APP_VER=$(node -e "const p=require(process.env.GITHUB_WORKSPACE + '/app/package.json'); const v=(p.devDependencies&&p.devDependencies.appium)||(p.dependencies&&p.dependencies.appium)||process.env.DEFAULT_APPIUM_VERSION; console.log(String(v).replace(/^[^0-9]*/,''));")
echo "appium_version=$APP_VER" >> $GITHUB_OUTPUT
echo "Detected Appium version: $APP_VER"

# Install phase using composite action
- name: Install Android App
id: install-android
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/ios-build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,17 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Read Appium version from package.json
id: appium-version
env:
DEFAULT_APPIUM_VERSION: ${{ env.DEFAULT_APPIUM_VERSION }}
GITHUB_WORKSPACE: ${{ github.workspace }}
run: |
echo "Reading Appium version from app/package.json"
APP_VER=$(node -e "const p=require(process.env.GITHUB_WORKSPACE + '/app/package.json'); const v=(p.devDependencies&&p.devDependencies.appium)||(p.dependencies&&p.dependencies.appium)||process.env.DEFAULT_APPIUM_VERSION; console.log(String(v).replace(/^[^0-9]*/,''));")
echo "appium_version=$APP_VER" >> $GITHUB_OUTPUT
echo "Detected Appium version: $APP_VER"

# Install phase using composite action template
- name: Install iOS App
Expand Down
6,962 changes: 2,728 additions & 4,234 deletions app/package-lock.json

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions app/test/fixtures/constants.fixture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const {
TIMEOUT,
PAUSE,
SCROLL,
GESTURE,
RETRY,
REGEX,
DEFAULTS,
LIMITS,
PLATFORM,
} = require('../pageobjects/utils/constants');

/**
* Global constants fixture for Mocha tests
* Exposes shared test constants under global.constants
*/

/**
* Setup global constants fixture before all tests
*/
exports.mochaGlobalSetup = async function () {
console.info('🔧 Setting up constants global fixture...');

global.constants = {
...(global.constants || {}),
TIMEOUT,
PAUSE,
SCROLL,
GESTURE,
RETRY,
REGEX,
DEFAULTS,
LIMITS,
PLATFORM,
dashForEmpty: DEFAULTS.DASH_FOR_EMPTY,
};

console.info('✅ Constants global fixture ready');
};
11 changes: 7 additions & 4 deletions app/test/fixtures/otp.fixture.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { waitForOTP } = require('../pageobjects/utils/airtable.service');
const { TIMEOUT, PAUSE } = require('../pageobjects/utils/constants');

/**
* Global OTP fixture for Mocha tests
Expand All @@ -13,7 +14,12 @@ const { waitForOTP } = require('../pageobjects/utils/airtable.service');
* @param {number} pollIntervalMs - Time between polls in milliseconds (default: 5000)
* @returns {Promise<{otp: string, record: object}>} - OTP and record data
*/
async function getOTPFromAirtable(email, afterTime, timeoutMs = 60000, pollIntervalMs = 5000) {
async function getOTPFromAirtable(
email,
afterTime,
timeoutMs = TIMEOUT.OTP_WAIT_MAX,
pollIntervalMs = PAUSE.OTP_POLL_INTERVAL,
) {
console.info(`\n=== OTP Fixture: Getting OTP for ${email} ===`);

try {
Expand All @@ -34,9 +40,6 @@ exports.mochaGlobalSetup = async function () {

// Make the OTP function available globally
global.getOTPFromAirtable = getOTPFromAirtable;
global.constants = {
dashForEmpty: '-',
}

// Verify Airtable configuration
const requiredEnvVars = [
Expand Down
10 changes: 8 additions & 2 deletions app/test/pageobjects/utils/airtable.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

const https = require('https');
const { TIMEOUT, PAUSE, REGEX } = require('./constants');

// Configuration - use getter functions to read env vars lazily (after wdio.conf.js loads .env)
const getAirtableConfig = () => {
Expand Down Expand Up @@ -122,7 +123,7 @@ async function markAsProcessed(recordId) {
* @returns {string|null} - The 6-digit OTP code or null if not found
*/
function extractOTPFromBody(bodyText) {
const match = bodyText.match(/verification code is:\s*(\d{6})/i);
const match = bodyText.match(REGEX.OTP_FROM_EMAIL_BODY);
return match ? match[1] : null;
}

Expand Down Expand Up @@ -185,7 +186,12 @@ async function fetchOTPByEmail(email) {
* @returns {Promise<{otp: string, record: object}>} - OTP and record
* @throws {Error} - If timeout is reached without finding OTP
*/
async function waitForOTP(email, timeoutMs = 60000, pollIntervalMs = 5000, afterTime = new Date()) {
async function waitForOTP(
email,
timeoutMs = TIMEOUT.OTP_WAIT_MAX,
pollIntervalMs = PAUSE.OTP_POLL_INTERVAL,
afterTime = new Date(),
) {
const config = getAirtableConfig();
console.info(`\n=== Waiting for OTP for: ${email} ===`);
console.info(` From Email filter: ${config.fromEmail}`);
Expand Down
12 changes: 6 additions & 6 deletions app/test/pageobjects/utils/auth.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ const headingPage = require('../base/heading.page');
const profilePage = require('../profile.page');
const userSettingsPage = require('../user-settings.page');
const { isAndroid } = require('./selectors');
const { TIMEOUT, PAUSE } = require('./constants');
const { TIMEOUT, PAUSE, REGEX } = require('./constants');
const { restartApp } = require('./app.helper');

const AIRTABLE_EMAIL = process.env.AIRTABLE_EMAIL || 'not-set';
const OTP_TIMEOUT_MS = 120000;
const POLL_INTERVAL_MS = 10000;
const OTP_TIMEOUT_MS = TIMEOUT.OTP_WAIT_MAX;
const POLL_INTERVAL_MS = PAUSE.OTP_POLL_INTERVAL;

/**
* Checks if the user is already logged in by checking for home page elements
Expand Down Expand Up @@ -124,7 +124,7 @@ async function loginWithOTP(email = AIRTABLE_EMAIL) {
);

// Verify we got a valid 6-digit OTP
if (!/^\d{6}$/.test(result.otp)) {
if (!REGEX.OTP_6_DIGITS.test(result.otp)) {
throw new Error(`Invalid OTP format: ${result.otp}`);
}
console.info('✓ OTP verification completed successfully');
Expand Down Expand Up @@ -155,7 +155,7 @@ async function loginWithOTP(email = AIRTABLE_EMAIL) {
async function waitForAppReady(timeout = TIMEOUT.SCREEN_READY) {
console.info('⏳ Waiting for app to reach ready state...');
const startTime = Date.now();
const pollInterval = 500;
const pollInterval = PAUSE.NAVIGATION;

while (Date.now() - startTime < timeout) {
// Check for home page elements
Expand Down Expand Up @@ -274,7 +274,7 @@ async function ensureLoggedOut() {
await userSettingsPage.signOut();

// Wait for welcome page to confirm logout
await welcomePage.welcomeTitle.waitForDisplayed({ timeout: 15000 });
await welcomePage.welcomeTitle.waitForDisplayed({ timeout: TIMEOUT.POST_LOGOUT_REDIRECT });
console.info('✅ User successfully logged out');
} catch (error) {
console.error('❌ Logout failed:', error.message);
Expand Down
85 changes: 85 additions & 0 deletions app/test/pageobjects/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ const TIMEOUT = {

/** Short element wait for fast-loading content */
SHORT_WAIT: 3000,

/** Extended wait for post-logout redirect screen */
POST_LOGOUT_REDIRECT: 15000,

/** Max wait for OTP retrieval polling */
OTP_WAIT_MAX: 60000,

/** Extended OTP e2e flow max wait */
OTP_E2E_MAX: 260000,

/** Additional buffer for OTP e2e test-level timeout */
OTP_E2E_BUFFER: 120000,

/** Max wait for auth flow navigation after OTP submit */
AUTH_FLOW_WAIT: 90000,

/** Standard suite setup timeout used by e2e specs */
TEST_SETUP_LONG: 150000,

/** Default Mocha suite timeout in WDIO config */
MOCHA_SUITE: 600000,
};

// ============ Pause/Delay Constants (milliseconds) ============
Expand Down Expand Up @@ -50,6 +71,15 @@ const PAUSE = {
/** Polling interval for status checks */
POLL_INTERVAL: 1000,

/** Polling interval for OTP inbox checks */
OTP_POLL_INTERVAL: 5000,

/** Polling interval used by long-running OTP e2e validation */
OTP_E2E_POLL: 13000,

/** Polling interval while waiting for post-auth home state */
AUTH_FLOW_POLL: 10000,

/** App restart pause after terminate */
APP_TERMINATE: 2000,

Expand Down Expand Up @@ -108,10 +138,65 @@ const RETRY = {
MAX_BACK_ATTEMPTS: 5,
};

// ============ Regex / Parsing Constants ============
const REGEX = {
/** Generic leading/trailing whitespace matcher */
TRIM_WHITESPACE: /^\s+|\s+$/g,

/** OTP extraction pattern from email body */
OTP_FROM_EMAIL_BODY: /verification code is:\s*(\d{6})/i,

/** OTP must be exactly six digits */
OTP_6_DIGITS: /^\d{6}$/,

/** Basic email format validation */
EMAIL_BASIC: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,

/** Entity ID formats */
ORDER_ID: /^ORD-\d{4}-\d{4}-\d{4}$/,
SUBSCRIPTION_ID: /^SUB-\d{4}-\d{4}-\d{4}$/,
AGREEMENT_ID: /^AGR-\d{4}-\d{4}-\d{4}$/,
AGREEMENT_ID_FLEX: /^AGR-(\d{4}-)+\d{4}$/,
INVOICE_ID: /^INV-(\d{4}-)+\d{4}$/,
CREDIT_MEMO_ID: /^CRD-(\d{4}-)+\d{4}$/,
USER_ID: /^USR-\d{4}-\d{4}$/,
USER_ID_FLEX: /^USR-(\d{4}-)+\d{4}$/,
PROGRAM_ID: /^PRG-\d{4}-\d{4}$/,
BUYER_ID: /^BUY-\d{4}-\d{4}$/,
BUYER_ID_FLEX: /^BUY-(\d{4}-?)+\d{4}$/,
LICENSEE_ID: /^LCE-\d{4}-\d{4}-\d{4}$/,
ENROLLMENT_ID: /^ENR-\d{4}-\d{4}-\d{4}$/,
};

// ============ Default / Sentinel Constants ============
const DEFAULTS = {
/** Placeholder used for empty text values */
DASH_FOR_EMPTY: '-',
};

// ============ Limits Constants ============
const LIMITS = {
/** Generic minimum expected count for list validations */
MIN_EXPECTED_COUNT: 1,
};

// ============ Platform Behavior Constants ============
const PLATFORM = {
/** iOS platform identifier */
IOS: 'ios',

/** Android platform identifier */
ANDROID: 'android',
};

module.exports = {
TIMEOUT,
PAUSE,
SCROLL,
GESTURE,
RETRY,
REGEX,
DEFAULTS,
LIMITS,
PLATFORM,
};
46 changes: 32 additions & 14 deletions app/test/specs/agreement-details.e2e.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const { expect } = require('@wdio/globals');

const agreementsPage = require('../pageobjects/agreements.page');
const agreementDetailsPage = require('../pageobjects/agreement-details.page');
const agreementsPage = require('../pageobjects/agreements.page');
const morePage = require('../pageobjects/more.page');
const { ensureLoggedIn } = require('../pageobjects/utils/auth.helper');
const { TIMEOUT, PAUSE, REGEX } = require('../pageobjects/utils/constants');
const navigation = require('../pageobjects/utils/navigation.page');
const { apiClient } = require('../utils/api-client');
const subscriptionDetailsPage = require('../pageobjects/subscription-details.page');

// E2E tests for Agreement Details Page, modeled after user-details.e2e.js

Expand All @@ -17,12 +17,12 @@ describe('Agreement Details Page', () => {
let apiAgreementData = null;

before(async function () {
this.timeout(150000);
this.timeout(TIMEOUT.TEST_SETUP_LONG);
await ensureLoggedIn();
await navigation.ensureHomePage({ resetFilters: false });
// Navigate to Agreements page via More menu
await agreementsPage.footer.moreTab.click();
await browser.pause(500);
await browser.pause(PAUSE.NAVIGATION);
await morePage.agreementsMenuItem.click();
await agreementsPage.waitForScreenReady();

Expand All @@ -37,15 +37,17 @@ describe('Agreement Details Page', () => {
if (apiAvailable && testAgreementId) {
try {
apiAgreementData = await apiClient.getAgreementById(testAgreementId);
console.log(JSON.stringify(apiAgreementData, null, 2));
console.info(JSON.stringify(apiAgreementData, null, 2));
console.info(`📊 Pre-fetched API data for agreement: ${testAgreementId}`);
} catch (error) {
console.warn(`⚠️ Failed to fetch API data: ${error.message}`);
}
}
}

console.info(`📊 Agreement Details test setup: hasAgreements=${hasAgreementsData}, apiAvailable=${apiAvailable}, testAgreementId=${testAgreementId}`);
console.info(
`📊 Agreement Details test setup: hasAgreements=${hasAgreementsData}, apiAvailable=${apiAvailable}, testAgreementId=${testAgreementId}`,
);

// Navigate to agreement details page once at the start
if (hasAgreementsData && testAgreementId) {
Expand Down Expand Up @@ -74,7 +76,7 @@ describe('Agreement Details Page', () => {
}
await expect(agreementDetailsPage.agreementIdText).toBeDisplayed();
const agreementId = await agreementDetailsPage.getItemId();
expect(agreementId).toMatch(/^AGR-(\d{4}-)+\d{4}$/);
expect(agreementId).toMatch(REGEX.AGREEMENT_ID_FLEX);
});

it('should display the status field', async function () {
Expand Down Expand Up @@ -127,7 +129,10 @@ describe('Agreement Details Page', () => {
this.skip();
return;
}
const billingCurrency = await agreementDetailsPage.getSimpleFieldValue('Billing currency', true);
const billingCurrency = await agreementDetailsPage.getSimpleFieldValue(
'Billing currency',
true,
);
expect(billingCurrency).toBeTruthy();
});
});
Expand Down Expand Up @@ -204,7 +209,10 @@ describe('Agreement Details Page', () => {
this.skip();
return;
}
const uiBillingCurrency = await agreementDetailsPage.getSimpleFieldValue('Billing currency', true);
const uiBillingCurrency = await agreementDetailsPage.getSimpleFieldValue(
'Billing currency',
true,
);
const apiBillingCurrency = apiAgreementData.price?.billingCurrency;
console.info(`[Billing Currency] UI: ${uiBillingCurrency} | API: ${apiBillingCurrency}`);
expect(uiBillingCurrency).toBe(apiBillingCurrency);
Expand All @@ -221,11 +229,21 @@ describe('Agreement Details Page', () => {
console.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.info(`Agreement ID: UI="${uiDetails.agreementId}" | API="${apiAgreementData.id}"`);
console.info(`Status: UI="${uiDetails.status}" | API="${apiAgreementData.status}"`);
console.info(`Vendor: UI="${uiDetails.vendor}" | API="${apiAgreementData.vendor?.name}"`);
console.info(`Product: UI="${uiDetails.product}" | API="${apiAgreementData.product?.name}"`);
console.info(`Client: UI="${uiDetails.client}" | API="${apiAgreementData.client?.name}"`);
console.info(`BaseCurrency: UI="${uiDetails.baseCurrency}" | API="${apiAgreementData.price?.currency}"`);
console.info(`BillingCurr: UI="${uiDetails.billingCurrency}" | API="${apiAgreementData.price?.billingCurrency}"`);
console.info(
`Vendor: UI="${uiDetails.vendor}" | API="${apiAgreementData.vendor?.name}"`,
);
console.info(
`Product: UI="${uiDetails.product}" | API="${apiAgreementData.product?.name}"`,
);
console.info(
`Client: UI="${uiDetails.client}" | API="${apiAgreementData.client?.name}"`,
);
console.info(
`BaseCurrency: UI="${uiDetails.baseCurrency}" | API="${apiAgreementData.price?.currency}"`,
);
console.info(
`BillingCurr: UI="${uiDetails.billingCurrency}" | API="${apiAgreementData.price?.billingCurrency}"`,
);
console.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
expect(uiDetails.agreementId).toBe(apiAgreementData.id);
});
Expand Down
Loading