Skip to content
Draft
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
122 changes: 122 additions & 0 deletions e2e/account.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import { APP_LOAD_TIMEOUT, navigateFromMenu, openMenu, openSchedule, submitCredentials, visiblePage } from './helpers';

const expectMenuLinks = async (page: Page, shown: string[], hidden: string[]) => {
const menu = await openMenu(page);
for (const name of shown) {
await expect(menu.getByRole('link', { name, exact: true })).toBeVisible();
}
for (const name of hidden) {
await expect(menu.getByRole('link', { name, exact: true })).toHaveCount(0);
}
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
};

const expectLoggedIn = (page: Page) => expectMenuLinks(page, ['Account', 'Support', 'Logout'], ['Login', 'Signup']);
const expectLoggedOut = (page: Page) => expectMenuLinks(page, ['Login', 'Support', 'Signup'], ['Account', 'Logout']);

const login = async (page: Page, username: string) => {
await navigateFromMenu(page, 'Login');
await submitCredentials(page, 'login-page', 'Login', username);
};

test.describe('Account', () => {
test.beforeEach(async ({ page }) => {
await openSchedule(page);
});

test('starts logged out', async ({ page }) => {
await expectLoggedOut(page);
});

test('does not log in without a username and password', async ({ page }) => {
await navigateFromMenu(page, 'Login');
const login = visiblePage(page, 'login-page');
await login.getByRole('textbox', { name: 'Username' }).fill('ionitron');
await login.getByRole('button', { name: 'Login' }).click();

await expect(page).toHaveURL(/\/login$/);
await expectLoggedOut(page);
});

test('logging in marks the user as logged in', async ({ page }) => {
await login(page, 'ionitron');

await expectLoggedIn(page);
});

test('the account page shows the username and can change it', async ({ page }) => {
await login(page, 'ionitron');
await navigateFromMenu(page, 'Account');

const account = visiblePage(page, 'account-page');
await expect(account.getByRole('heading', { name: 'ionitron' })).toBeVisible();

// Typing into the alert before it has finished opening can be lost, so wait for it to open.
const opened = page.evaluate(
() =>
new Promise<void>((resolve) =>
document.addEventListener('ionAlertDidPresent', () => resolve(), { once: true }),
),
);
await account.getByText('Change Username').click();
await opened;
const alert = page.getByRole('alertdialog', { name: 'Change Username' });
const input = alert.getByRole('textbox', { name: 'username' });
await expect(input).toHaveValue('ionitron');
await input.fill('capacitor');
await alert.getByRole('button', { name: 'Ok' }).click();

await expect(account.getByRole('heading', { name: 'capacitor' })).toBeVisible();

// The new username is saved.
await page.reload();
await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'capacitor' })).toBeVisible({
timeout: APP_LOAD_TIMEOUT,
});
});

test('logging out returns to the logged out state', async ({ page }) => {
await login(page, 'ionitron');

await navigateFromMenu(page, 'Logout');

await expect(page).toHaveURL(/\/tabs\/schedule$/);
await expectLoggedOut(page);

// Logging out is saved too.
await page.reload();
await expect(page.getByRole('button', { name: 'menu' })).toBeVisible({ timeout: APP_LOAD_TIMEOUT });
await expectLoggedOut(page);
});

test('signing up logs the user in', async ({ page }) => {
await navigateFromMenu(page, 'Signup');
await submitCredentials(page, 'signup-page', 'Create', 'newbie');

await expectLoggedIn(page);
await navigateFromMenu(page, 'Account');
await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'newbie' })).toBeVisible();
});

test('the login page links to signup', async ({ page }) => {
await navigateFromMenu(page, 'Login');
await visiblePage(page, 'login-page').getByRole('button', { name: 'Signup' }).click();

await expect(page).toHaveURL(/\/signup$/);
});

test('stays logged in after a reload', async ({ page }) => {
await login(page, 'ionitron');

await page.reload();
await expect(page.getByRole('button', { name: 'menu' })).toBeVisible({ timeout: APP_LOAD_TIMEOUT });

await expectLoggedIn(page);
await navigateFromMenu(page, 'Account');
await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'ionitron' })).toBeVisible();
});
});
16 changes: 0 additions & 16 deletions e2e/app.spec.ts

This file was deleted.

83 changes: 83 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';

// The app renders nothing until it has fetched the conference data, which can be slow on CI runners.
export const APP_LOAD_TIMEOUT = 15_000;

/**
* Opens the app for the first time and skips the tutorial, landing on the schedule.
*/
export const openSchedule = async (page: Page): Promise<void> => {
await page.goto('/');
await page.getByRole('button', { name: 'Skip' }).click({ timeout: APP_LOAD_TIMEOUT });
await expect(page).toHaveURL(/\/tabs\/schedule$/);
await expect(sessionLink(page, 'Breakfast')).toBeVisible();
};

/**
* Ionic keeps pages it has shown in the DOM and hides them, so scope to the page that is showing.
*/
export const visiblePage = (page: Page, id: string): Locator => page.locator(`#${id}:not(.ion-page-hidden)`);

export const visibleTitle = (page: Page): Locator => page.locator('ion-title').filter({ visible: true }).first();

// A session in the schedule list. Its accessible name starts with the session name, followed by its time and room.
export const sessionLink = (page: Page, name: string): Locator =>
visiblePage(page, 'schedule-page').getByRole('link', { name: new RegExp(`^${escapeRegExp(name)} \\d`) });

// The schedule's toolbar buttons only show an icon and have no accessible name, so find them by position.
const scheduleEndButtons = (page: Page): Locator =>
visiblePage(page, 'schedule-page').locator('ion-header ion-buttons[slot="end"] ion-button');
export const searchButton = (page: Page): Locator => scheduleEndButtons(page).first();
export const filterButton = (page: Page): Locator => scheduleEndButtons(page).last();

// The session detail's favorite button is the first icon-only button at the end of its toolbar.
export const favoriteButton = (page: Page): Locator =>
visiblePage(page, 'session-detail-page').locator('ion-buttons[slot="end"] ion-button').first();

export const openMenu = async (page: Page): Promise<Locator> => {
// Wait for any page transition to finish so only the current page's menu button is showing.
const menuButton = page.getByRole('button', { name: 'menu', exact: true }).filter({ visible: true });
await expect(menuButton).toHaveCount(1);
await menuButton.click();
const menu = page.getByRole('navigation', { name: 'menu' });
await expect(menu).toBeVisible();
return menu;
};

export const navigateFromMenu = async (page: Page, name: string): Promise<void> => {
const menu = await openMenu(page);
await menu.getByRole('link', { name, exact: true }).click();
await expect(menu).toBeHidden();
};

/**
* Switches the schedule between its All and Favorites segments.
*/
export const showSegment = async (page: Page, name: 'All' | 'Favorites'): Promise<void> => {
const schedule = visiblePage(page, 'schedule-page');
// The segment button's host receives the pointer events, not its inner tab element.
await schedule.locator('ion-segment-button', { hasText: name }).click();
// Wait for the switch so the other segment's sessions aren't matched while they're still showing.
await expect(schedule.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'true');
};

export const backButton = (page: Page, pageId: string): Locator =>
visiblePage(page, pageId).getByRole('button', { name: 'back', exact: true });

export const submitCredentials = async (
page: Page,
pageId: string,
button: string,
username: string,
): Promise<void> => {
const form = visiblePage(page, pageId);
await form.getByRole('textbox', { name: 'Username' }).fill(username);
await form.getByRole('textbox', { name: 'Password' }).fill('secret');
await form.getByRole('button', { name: button }).click();
await expect(page).toHaveURL(/\/tabs\/schedule$/);
};

function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
115 changes: 115 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect, test } from '@playwright/test';

import { navigateFromMenu, openMenu, openSchedule, visiblePage, visibleTitle } from './helpers';

test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await openSchedule(page);
});

test('the tab bar reaches each tab', async ({ page }) => {
for (const tab of [
{ name: 'Speakers', url: /\/tabs\/speakers$/, title: 'Speakers' },
{ name: 'Map', url: /\/tabs\/map$/, title: 'Map' },
{ name: 'Schedule', url: /\/tabs\/schedule$/, title: 'Schedule' },
]) {
await page.getByRole('tab', { name: tab.name }).click();

await expect(page).toHaveURL(tab.url);
await expect(visibleTitle(page)).toHaveText(tab.title);
await expect(page.getByRole('tab', { name: tab.name })).toHaveAttribute('aria-selected', 'true');
}

// The About page has no title in its toolbar, just an About heading.
await page.getByRole('tab', { name: 'About' }).click();
await expect(page).toHaveURL(/\/tabs\/about$/);
await expect(visiblePage(page, 'about-page').getByRole('heading', { name: 'About' })).toBeVisible();
});

for (const item of [
{ name: 'Speakers', url: /\/tabs\/speakers$/, title: 'Speakers' },
{ name: 'Map', url: /\/tabs\/map$/, title: 'Map' },
{ name: 'Support', url: /\/support$/, title: 'Support' },
{ name: 'Login', url: /\/login$/, title: 'Login' },
{ name: 'Signup', url: /\/signup$/, title: 'Signup' },
{ name: 'Schedule', url: /\/tabs\/schedule$/, title: 'Schedule' },
]) {
test(`the menu opens the ${item.name} page`, async ({ page }) => {
// Start somewhere else so that opening Schedule is a navigation too.
if (item.name === 'Schedule') {
await navigateFromMenu(page, 'Speakers');
await expect(page).toHaveURL(/\/tabs\/speakers$/);
}

await navigateFromMenu(page, item.name);

await expect(page).toHaveURL(item.url);
await expect(visibleTitle(page)).toHaveText(item.title);
});
}

test('the menu opens the About page', async ({ page }) => {
await navigateFromMenu(page, 'About');

await expect(page).toHaveURL(/\/tabs\/about$/);
await expect(visiblePage(page, 'about-page')).toContainText('The Ionic Conference is a one-day event');
});

test('the map page shows a marker for each location', async ({ page }) => {
// Keep the test independent of the network by not loading the map tiles.
await page.route(/tile\.openstreetmap\.org/, (route) => route.abort());

await page.getByRole('tab', { name: 'Map' }).click();

const map = page.locator('.map-canvas');
await expect(map).toHaveClass(/leaflet-container/);
await expect(map).toHaveClass(/show-map/);
await expect(map.locator('.leaflet-marker-icon')).toHaveCount(4);
});

test('the support page sends a message and clears the form', async ({ page }) => {
await navigateFromMenu(page, 'Support');
const support = visiblePage(page, 'support-page');
const notice = page.getByText('This does not actually send a support request.');
await expect(notice).toBeVisible();
// The page can only show one toast at a time and skips the confirmation while the notice is open (see the report).
await expect(notice).toBeHidden({ timeout: 10_000 });

const message = support.getByRole('textbox', { name: 'Enter your support message below' });
await message.fill('The app is great!');
await support.getByRole('button', { name: 'Submit' }).click();

await expect(page.getByText('Your support request has been sent.')).toBeVisible();
await expect(message).toHaveValue('');
});

test('the support page does not send an empty message', async ({ page }) => {
await navigateFromMenu(page, 'Support');
const support = visiblePage(page, 'support-page');

await support.getByRole('button', { name: 'Submit' }).click();

// The "Support message is required" error text isn't shown (see the report), so check nothing was sent.
await expect(support.getByRole('textbox', { name: 'Enter your support message below' })).toBeVisible();
await expect(page.getByText('Your support request has been sent.')).toHaveCount(0);
});

test('the dark mode toggle switches the dark palette', async ({ page }) => {
const app = page.locator('ion-app');
await expect(app).not.toHaveClass(/ion-palette-dark/);

const menu = await openMenu(page);
const toggle = menu.getByRole('switch', { name: 'Dark Mode' });
// The toggle's host element receives the pointer events, not its inner switch element.
const toggleHost = menu.locator('ion-toggle');
await toggleHost.click();

await expect(toggle).toBeChecked();
await expect(app).toHaveClass(/ion-palette-dark/);

await toggleHost.click();

await expect(toggle).not.toBeChecked();
await expect(app).not.toHaveClass(/ion-palette-dark/);
});
});
Loading
Loading