Skip to content
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
11 changes: 11 additions & 0 deletions features/share-calculator-results.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Feature: Share Calculator Results Table
As a user of the Share/Stock Calculator
I want the results table to render accessibly and update dynamically

Scenario: Results table renders correctly and is accessible after submitting valid inputs
Given I open the Share Calculator page
When I enter valid inputs for shares and date and submit the form
Then I should see an accessible results table rendered dynamically without a page reload
And a loading indicator should appear and disappear during calculation when present
And the results table should contain column headers and at least one result row
And the page URL should not change after submission
82 changes: 82 additions & 0 deletions src/pages/ShareCalculatorPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

export class ShareCalculatorPage extends BasePage {
// Page URL
static readonly url = 'https://investorcenter.slb.com/stock/share-calculator';

// Locators - use robust selectors where possible
readonly sharesInput = 'input[name="shares"], input[id*="shares"], input[aria-label*="Shares"]';
readonly dateInput = 'input[type="date"], input[name*="date"], input[id*="date"]';
readonly submitButton = 'button[type="submit"], button:has-text("Calculate"), button:has-text("Submit")';
readonly resultsTable = 'table:visible, [role="table"]:visible';
readonly resultsTableHeader = `${this.resultsTable} th`;
readonly resultsTableRows = `${this.resultsTable} tbody tr`;
readonly loadingIndicator = '[aria-busy="true"], .loading, .spinner, .skeleton';
readonly errorMessage = '[role="alert"], .error, .form-error';

constructor(page: Page) {
super(page);
}

async navigate(): Promise<void> {
await this.navigateTo(ShareCalculatorPage.url);
await this.waitForLoadState();
}

async fillShares(value: string): Promise<void> {
const shares = this.page.locator(this.sharesInput).first();
await shares.waitFor({ state: 'visible', timeout: 10000 });
await shares.fill(value);
}

async fillDate(value: string): Promise<void> {
const date = this.page.locator(this.dateInput).first();
await date.waitFor({ state: 'visible', timeout: 10000 });
await date.fill(value);
}

async submit(): Promise<void> {
const submit = this.page.locator(this.submitButton).first();
await submit.waitFor({ state: 'visible', timeout: 10000 });
await Promise.all([
this.page.waitForResponse(resp => resp.request().method() === 'POST' || resp.request().method() === 'GET', { timeout: 15000 }).catch(() => {}),
submit.click(),
]);
}

async waitForLoadingToFinish(timeout = 15000): Promise<void> {
const loader = this.page.locator(this.loadingIndicator).first();
try {
if (await loader.isVisible()) {
await loader.waitFor({ state: 'hidden', timeout });
}
} catch {
// ignore
}
}

async getResultsTableHeaders(): Promise<string[]> {
const headers = await this.page.locator(this.resultsTableHeader).allTextContents();
return headers.map(h => h.trim()).filter(h => h.length > 0);
}

async getResultsRowCount(): Promise<number> {
return await this.page.locator(this.resultsTableRows).count();
}

async isResultsTableAccessible(): Promise<boolean> {
const table = this.page.locator(this.resultsTable).first();
if (!(await table.count())) return false;
// Check for semantic markup
const role = await table.getAttribute('role');
const ariaLabel = await table.getAttribute('aria-label');
const hasThead = (await table.locator('thead').count()) > 0;
const hasTh = (await table.locator('th').count()) > 0;
return hasTh || hasThead || !!ariaLabel || role === 'table';
}

async hasError(): Promise<boolean> {
return (await this.page.locator(this.errorMessage).count()) > 0;
}
}
65 changes: 65 additions & 0 deletions src/steps/share-calculator-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { getPage } from '../support/browser';
import { ShareCalculatorPage } from '../pages/ShareCalculatorPage';

let pageObj: ShareCalculatorPage;

Given('I open the Share Calculator page', async function () {
const page = getPage();
pageObj = new ShareCalculatorPage(page);
await pageObj.navigate();
// Ensure page loaded
const url = await pageObj.getUrl();
expect(url).toContain('share-calculator');
});

When('I enter valid inputs for shares and date and submit the form', async function () {
await pageObj.fillShares('100');
await pageObj.fillDate('2023-06-15');
// Capture URL before submit
(this as any).preSubmitUrl = await pageObj.getUrl();
await pageObj.submit();
});

Then('I should see an accessible results table rendered dynamically without a page reload', async function () {
const preUrl = (this as any).preSubmitUrl;
await pageObj.waitForLoadingToFinish();
// URL should remain same (no page reload)
const postUrl = await pageObj.getUrl();
expect(postUrl).toBe(preUrl);

const hasTable = (await pageObj.getResultsRowCount()) > 0 && (await pageObj.getResultsTableHeaders()).length > 0;
expect(hasTable).toBeTruthy();
const accessible = await pageObj.isResultsTableAccessible();
expect(accessible).toBeTruthy();
});

Then('a loading indicator should appear and disappear during calculation when present', async function () {
// If there is a loader, its visibility should toggle
const loader = await getPage().locator(pageObj.loadingIndicator).first();
// We cannot deterministically assert it appears, but if it exists, ensure it hides eventually
if (await loader.count() > 0) {
try {
if (await loader.isVisible()) {
await loader.waitFor({ state: 'hidden', timeout: 15000 });
}
} catch {
// if it never disappears, fail
expect(await loader.isVisible()).toBeFalsy();
}
}
});

Then('the results table should contain column headers and at least one result row', async function () {
const headers = await pageObj.getResultsTableHeaders();
const rows = await pageObj.getResultsRowCount();
expect(headers.length).toBeGreaterThan(0);
expect(rows).toBeGreaterThan(0);
});

Then('the page URL should not change after submission', async function () {
const preUrl = (this as any).preSubmitUrl;
const postUrl = await pageObj.getUrl();
expect(postUrl).toBe(preUrl);
});