Skip to content
10 changes: 10 additions & 0 deletions e2e/pages/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,14 @@ export class LoginPage {
async goto() {
await this.page.goto(`${process.env.E2E_BASE_URL}/spa/login`);
}

async login(username: string, password: string) {
await this.goto();
await this.page.getByLabel(/username/i).fill(username);
await this.page.getByText(/continue/i).click();
await this.page.getByLabel(/^password$/i).waitFor({ state: 'visible', timeout: 10000 });
await this.page.getByLabel(/^password$/i).fill(password);
await this.page.getByRole('button', { name: /log in/i }).click();
await this.page.waitForLoadState('networkidle');
}
}
27 changes: 20 additions & 7 deletions e2e/specs/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { test } from '../core';
import { expect } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import { LoginPage } from '../pages';

async function selectLocationIfRequired(page: Page) {
const locationPicker = page.getByText(/outpatient clinic/i);
const isLocationPickerVisible = await locationPicker
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);

if (isLocationPickerVisible) {
await locationPicker.click();
await page.getByRole('button', { name: /confirm/i }).click();
}
}

test.use({ storageState: { cookies: [], origins: [] } });

test('Login as Admin user', async ({ page }) => {
Expand All @@ -19,25 +32,25 @@ test('Login as Admin user', async ({ page }) => {
});

await test.step('And I enter my password', async () => {
await page.getByLabel(/^password$/i).waitFor({ state: 'visible', timeout: 10000 });
await page.getByLabel(/^password$/i).fill(`${process.env.E2E_USER_ADMIN_PASSWORD}`);
});

await test.step('And I click the `Log in` button', async () => {
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForLoadState('domcontentloaded');
});

await test.step('And I choose a login location', async () => {
await expect(page).toHaveURL(`${process.env.E2E_BASE_URL}/spa/login/location`);
await page.getByText(/outpatient clinic/i).click();
await page.getByRole('button', { name: /confirm/i }).click();
await test.step('And I choose a login location if required', async () => {
await selectLocationIfRequired(page);
});

await test.step('Then I should get navigated to the home page', async () => {
await expect(page).toHaveURL(`${process.env.E2E_BASE_URL}/spa/home/service-queues`);
await expect(page).toHaveURL(/\/spa\/home/);
});

await test.step('And I should see the location picker in top nav', async () => {
await expect(topNav.getByText(/outpatient clinic/i)).toBeVisible();
await expect(topNav.getByText(/(outpatient clinic|inpatient ward)/i)).toBeVisible();
});

await test.step('When I click on the my account button', async () => {
Expand Down
22 changes: 19 additions & 3 deletions e2e/specs/navbar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { test } from '../core';
import { expect } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
import { HomePage, LoginPage } from '../pages';

async function selectLocationIfRequired(page: Page) {
const locationPicker = page.getByText(/outpatient clinic/i);
const isLocationPickerVisible = await locationPicker
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);

if (isLocationPickerVisible) {
await locationPicker.click();
await page.getByRole('button', { name: /confirm/i }).click();
}
}

test.use({ storageState: { cookies: [], origins: [] } });

test('View action buttons in the navbar', async ({ page }) => {
const loginPage = new LoginPage(page);
const homePage = new HomePage(page);
Expand All @@ -11,10 +26,11 @@ test('View action buttons in the navbar', async ({ page }) => {
await loginPage.goto();
await page.getByLabel(/username/i).fill(`${process.env.E2E_USER_ADMIN_USERNAME}`);
await page.getByText(/continue/i).click();
await page.getByLabel(/^password$/i).waitFor({ state: 'visible', timeout: 10000 });
await page.getByLabel(/^password$/i).fill(`${process.env.E2E_USER_ADMIN_PASSWORD}`);
await page.getByRole('button', { name: /log in/i }).click();
await page.getByText(/outpatient clinic/i).click();
await page.getByRole('button', { name: /confirm/i }).click();
await page.waitForLoadState('domcontentloaded');
await selectLocationIfRequired(page);
});

await test.step('When I visit the home page', async () => {
Expand Down
44 changes: 28 additions & 16 deletions packages/apps/esm-login-app/src/login/login.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const Login: React.FC = () => {
useEffect(() => {
if (showPasswordOnSeparateScreen) {
if (showPasswordField) {
passwordInputRef.current?.focus();
// Only focus password input if it's empty (to preserve browser autofilled values)
if (!passwordInputRef.current?.value) {
passwordInputRef.current?.focus();
}
} else {
usernameInputRef.current?.focus();
}
Expand Down Expand Up @@ -157,6 +160,8 @@ const Login: React.FC = () => {
<TextInput
id="username"
type="text"
name="username"
autoComplete="username"
labelText={t('username', 'Username')}
value={username}
onChange={changeUsername}
Expand All @@ -165,19 +170,25 @@ const Login: React.FC = () => {
autoFocus
/>
{showPasswordOnSeparateScreen ? (
showPasswordField ? (
<>
<>
{/* Password input is always in DOM for browser autofill support, but visually hidden until username step is complete */}
<div className={showPasswordField ? undefined : styles.hiddenPasswordField}>
<PasswordInput
id="password"
labelText={t('password', 'Password')}
name="password"
autoComplete="current-password"
onChange={changePassword}
ref={passwordInputRef}
required
required={showPasswordField}
value={password}
showPasswordLabel={t('showPassword', 'Show password')}
invalidText={t('validValueRequired', 'A valid value is required')}
aria-hidden={!showPasswordField}
tabIndex={showPasswordField ? 0 : -1}
/>
</div>
{showPasswordField ? (
<Button
type="submit"
className={styles.continueButton}
Expand All @@ -191,24 +202,25 @@ const Login: React.FC = () => {
t('login', 'Log in')
)}
</Button>
</>
) : (
<Button
className={styles.continueButton}
renderIcon={(props) => <ArrowRightIcon size={24} {...props} />}
iconDescription="Continue to password"
onClick={continueLogin}
disabled={!isLoginEnabled}
>
{t('continue', 'Continue')}
</Button>
)
) : (
<Button
className={styles.continueButton}
renderIcon={(props) => <ArrowRightIcon size={24} {...props} />}
iconDescription="Continue to password"
onClick={continueLogin}
disabled={!isLoginEnabled}
>
{t('continue', 'Continue')}
</Button>
)}
</>
) : (
<>
<PasswordInput
id="password"
labelText={t('password', 'Password')}
name="password"
autoComplete="current-password"
onChange={changePassword}
ref={passwordInputRef}
required
Expand Down
11 changes: 11 additions & 0 deletions packages/apps/esm-login-app/src/login/login.scss
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,14 @@
bottom: 100%;
left: 0;
}

// Hidden password field for browser autofill support
// Uses visibility:hidden instead of display:none to keep the input in the DOM
// This allows browsers to autofill both username and password credentials
.hiddenPasswordField {
visibility: hidden;
height: 0;
overflow: hidden;
position: absolute;
pointer-events: none;
}
35 changes: 32 additions & 3 deletions packages/apps/esm-login-app/src/login/login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe('Login', () => {
expect(loginButton).toBeInTheDocument();
});

it('should not render the password field when the showPasswordOnSeparateScreen config is true (default)', async () => {
it('should render password field hidden but present for autofill when showPasswordOnSeparateScreen config is true (default)', async () => {
mockUseConfig.mockReturnValue({
...mockConfig,
});
Expand All @@ -187,12 +187,14 @@ describe('Login', () => {

const usernameInput = screen.queryByRole('textbox', { name: /username/i });
const continueButton = screen.queryByRole('button', { name: /Continue/i });
const passwordInput = screen.queryByLabelText(/password/i);
const passwordInput = screen.queryByLabelText(/^password$/i);
const loginButton = screen.queryByRole('button', { name: /log in/i });

expect(usernameInput).toBeInTheDocument();
expect(continueButton).toBeInTheDocument();
expect(passwordInput).not.toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you try expect(passwordInput).not.toBeVisible()? Don't worry if it doesn't work, but if it does work it would be a nice way to express this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave expect(passwordInput).not.toBeVisible() a try, but unfortunately, it didn't work as expected in this environment.

jest-dom considers the element "visible" because it's present in the DOM with visibility: hidden applied via a CSS class, rather than inline styles or the hidden attribute, which the matcher doesn't fully process in this test setup.

I've kept the explicit aria-hidden and tabIndex assertions to verify the behavior correctly.

expect(passwordInput).toHaveAttribute('aria-hidden', 'true');
expect(passwordInput).toHaveAttribute('tabIndex', '-1');
expect(loginButton).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -283,4 +285,31 @@ describe('Login', () => {

expect(usernameInput).toHaveFocus();
});

it('should make password input visible and accessible after continuing from username step', async () => {
const user = userEvent.setup();
mockUseConfig.mockReturnValue({
...mockConfig,
});

renderWithRouter(
Login,
{},
{
route: '/login',
},
);

const usernameInput = screen.getByRole('textbox', { name: /username/i });
const continueButton = screen.getByRole('button', { name: /Continue/i });

await user.type(usernameInput, 'testuser');
await user.click(continueButton);

const passwordInput = screen.getByLabelText(/^password$/i);

// Password should now be visible and accessible
expect(passwordInput).toHaveAttribute('aria-hidden', 'false');
expect(passwordInput).toHaveAttribute('tabIndex', '0');
});
});