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
34 changes: 34 additions & 0 deletions e2e/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,43 @@ test.describe('Account', () => {
await login.getByRole('button', { name: 'Login' }).click();

await expect(page).toHaveURL(/\/login$/);
await expect(login.getByText('Password is required')).toBeVisible();
await expect(login.getByText('Username is required')).toHaveCount(0);
await expectLoggedOut(page);
});

for (const form of [
{ menu: 'Login', pageId: 'login-page', button: 'Login' },
{ menu: 'Signup', pageId: 'signup-page', button: 'Create' },
]) {
test(`${form.menu} shows the required messages when submitted empty`, async ({ page }) => {
await navigateFromMenu(page, form.menu);
const formPage = visiblePage(page, form.pageId);
await expect(formPage.getByText('Username is required')).toBeHidden();

await formPage.getByRole('button', { name: form.button }).click();

await expect(formPage.getByText('Username is required')).toBeVisible();
await expect(formPage.getByText('Password is required')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${form.menu.toLowerCase()}$`));
});

test(`${form.menu} shows a required message when an empty field is left`, async ({ page }) => {
await navigateFromMenu(page, form.menu);
const formPage = visiblePage(page, form.pageId);
const username = formPage.getByRole('textbox', { name: 'Username' });

await username.focus();
await username.blur();

await expect(formPage.getByText('Username is required')).toBeVisible();
await expect(formPage.getByText('Password is required')).toBeHidden();

await username.fill('ionitron');
await expect(formPage.getByText('Username is required')).toBeHidden();
});
}

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

Expand Down
15 changes: 8 additions & 7 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ export const visibleTitle = (page: Page): Locator => page.locator('ion-title').f
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();
export const searchButton = (page: Page): Locator =>
visiblePage(page, 'schedule-page').getByRole('button', { name: 'Search', exact: true });
export const filterButton = (page: Page): Locator =>
visiblePage(page, 'schedule-page').getByRole('button', { name: 'Filter', exact: true });

// The session detail's favorite button is the first icon-only button at the end of its toolbar.
// The session detail's favorite button is labelled with what pressing it does.
export const favoriteButton = (page: Page): Locator =>
visiblePage(page, 'session-detail-page').locator('ion-buttons[slot="end"] ion-button').first();
visiblePage(page, 'session-detail-page').getByRole('button', { name: 'Favorite', exact: true });
export const removeFavoriteButton = (page: Page): Locator =>
visiblePage(page, 'session-detail-page').getByRole('button', { name: 'Remove favorite', exact: true });

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.
Expand Down
51 changes: 48 additions & 3 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ test.describe('Navigation', () => {
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' });
Expand All @@ -83,15 +82,61 @@ test.describe('Navigation', () => {
await expect(message).toHaveValue('');
});

test('the support page shows the confirmation even while the notice is still open', 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();

await support.getByRole('textbox', { name: 'Enter your support message below' }).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(notice).toBeHidden();
});

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

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(support.getByText('Support message is required')).toBeVisible();
await expect(page.getByText('Your support request has been sent.')).toHaveCount(0);

// Typing a message clears the error.
await support.getByRole('textbox', { name: 'Enter your support message below' }).fill('Hello');
await expect(support.getByText('Support message is required')).toBeHidden();
});

test('the support message shows its required message when left empty', async ({ page }) => {
await navigateFromMenu(page, 'Support');
const support = visiblePage(page, 'support-page');
const message = support.getByRole('textbox', { name: 'Enter your support message below' });

await message.focus();
await message.blur();

await expect(support.getByText('Support message is required')).toBeVisible();
});

test('the share button opens the social share buttons', async ({ page }) => {
const schedule = visiblePage(page, 'schedule-page');
await schedule.getByRole('button', { name: 'Share', exact: true }).click();
for (const name of ['Instagram', 'Twitter', 'Facebook']) {
await expect(schedule.getByRole('button', { name, exact: true })).toBeVisible();
}
await schedule.getByRole('button', { name: 'Vimeo', exact: true }).click();

await expect(page.getByText('Posting to Vimeo')).toBeVisible();
});

test('the About page opens its options from the More options button', async ({ page }) => {
await page.getByRole('tab', { name: 'About' }).click();
await visiblePage(page, 'about-page').getByRole('button', { name: 'More options', exact: true }).click();

await expect(page.getByText('Learn Ionic')).toBeVisible();
});

test('the dark mode toggle switches the dark palette', async ({ page }) => {
Expand Down
72 changes: 66 additions & 6 deletions e2e/session-detail.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import { backButton, favoriteButton, openSchedule, showSegment, sessionLink, visiblePage } from './helpers';
import {
backButton,
favoriteButton,
openSchedule,
removeFavoriteButton,
showSegment,
sessionLink,
visiblePage,
} from './helpers';

const detail = (page: Page) => visiblePage(page, 'session-detail-page');
const schedule = (page: Page) => visiblePage(page, 'schedule-page');

const toggleFavoriteFromDetail = async (page: Page, name: string) => {
const toggleFavoriteFromDetail = async (page: Page, name: string, button: typeof favoriteButton) => {
await showSegment(page, 'All');
await sessionLink(page, name).click();
await expect(detail(page).getByRole('heading', { level: 1 })).toHaveText(name);
await favoriteButton(page).click();
await button(page).click();
await backButton(page, 'session-detail-page').click();
await showSegment(page, 'Favorites');
};
Expand All @@ -19,6 +27,8 @@ const toggleFavoriteFromDetail = async (page: Page, name: string) => {
const swipeOpen = async (page: Page, name: string) => {
const item = sessionLink(page, name);
await expect(item).toBeVisible();
// Wait for an item that was just closed to finish closing, or the swipe is lost.
await expect(schedule(page).locator('ion-item-sliding.item-sliding-active-slide')).toHaveCount(0);
const box = (await item.boundingBox())!;
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width - 20, y);
Expand All @@ -27,6 +37,8 @@ const swipeOpen = async (page: Page, name: string) => {
await page.mouse.up();
};

const visibleSessions = (page: Page) => schedule(page).getByRole('link').filter({ visible: true });

const itemOption = (page: Page, name: string) =>
schedule(page).getByRole('button', { name, exact: true }).filter({ visible: true });

Expand All @@ -47,16 +59,27 @@ test.describe('Session detail', () => {
});

test('favoriting from the detail page adds the session to Favorites', async ({ page }) => {
await toggleFavoriteFromDetail(page, 'Ionic Tooling');
await toggleFavoriteFromDetail(page, 'Ionic Tooling', favoriteButton);

await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();
});

test("the favorite button's label says whether it adds or removes the favorite", async ({ page }) => {
await sessionLink(page, 'Ionic Tooling').click();
await expect(removeFavoriteButton(page)).toHaveCount(0);

await favoriteButton(page).click();

await expect(removeFavoriteButton(page)).toBeVisible();
await expect(favoriteButton(page)).toHaveCount(0);
await expect(detail(page).getByRole('button', { name: 'Share', exact: true })).toBeVisible();
});

test('unfavoriting from the detail page removes the session from Favorites', async ({ page }) => {
await toggleFavoriteFromDetail(page, 'Ionic Tooling');
await toggleFavoriteFromDetail(page, 'Ionic Tooling', favoriteButton);
await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();

await toggleFavoriteFromDetail(page, 'Ionic Tooling');
await toggleFavoriteFromDetail(page, 'Ionic Tooling', removeFavoriteButton);

await expect(sessionLink(page, 'Ionic Tooling')).toBeHidden();
await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
Expand Down Expand Up @@ -86,4 +109,41 @@ test.describe('Session detail', () => {
await expect(sessionLink(page, 'Ionic Tooling')).toBeHidden();
await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
});

test('favoriting a session that is already a favorite asks to remove it, and Cancel keeps it', async ({ page }) => {
await swipeOpen(page, 'Ionic Tooling');
await itemOption(page, 'Favorite').click();
await expect(page.getByText('Ionic Tooling was successfully added as a favorite.')).toBeVisible();

await swipeOpen(page, 'Ionic Tooling');
await itemOption(page, 'Favorite').click();
const alert = page.getByRole('alertdialog', { name: 'Favorite already added' });
await alert.getByRole('button', { name: 'Cancel' }).click();
await expect(alert).toBeHidden();

await showSegment(page, 'Favorites');
await expect(visibleSessions(page)).toHaveCount(1);
await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();

// Removing it once removes it, so it wasn't added a second time.
await swipeOpen(page, 'Ionic Tooling');
await itemOption(page, 'Remove').click();
await page.getByRole('alertdialog', { name: 'Remove Favorite' }).getByRole('button', { name: 'Remove' }).click();
await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
});

test('Remove in the already-a-favorite prompt unfavorites the session', async ({ page }) => {
await swipeOpen(page, 'Ionic Tooling');
await itemOption(page, 'Favorite').click();

await swipeOpen(page, 'Ionic Tooling');
await itemOption(page, 'Favorite').click();
await page
.getByRole('alertdialog', { name: 'Favorite already added' })
.getByRole('button', { name: 'Remove' })
.click();

await showSegment(page, 'Favorites');
await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
});
});
2 changes: 2 additions & 0 deletions e2e/speakers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ test.describe('Speakers', () => {
const detail = visiblePage(page, 'speaker-detail');
await expect(detail.getByRole('heading', { name: 'Burt Bear' })).toBeVisible();
await expect(detail).toContainText('Burt is a Bear.');
await expect(detail.getByRole('button', { name: 'Contact', exact: true })).toBeVisible();
await expect(detail.getByRole('button', { name: 'Share', exact: true })).toBeVisible();
});

test("opens a speaker's session from the speaker list", async ({ page }) => {
Expand Down
17 changes: 17 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
"semanticCommits": "enabled",
"ignorePaths": ["android/**", "ios/**"],
"gitIgnoredAuthors": ["hi@ionicframework.com"],
"platformAutomerge": false,
"lockFileMaintenance": {
"enabled": true,
"automerge": true
},
"packageRules": [
{
"matchPackageNames": ["@ionic/**", "!@ionic/eslint-config", "!@ionic/prettier-config"],
Expand Down Expand Up @@ -54,6 +59,18 @@
"typescript"
],
"groupName": "dev-tools"
},
{
"description": "Automerge minor and patch updates once CI passes. Majors and 0.x packages, where a minor can break, still need review.",
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": "!/^0/",
"automerge": true
},
{
"description": "CI only tests the web build, so Capacitor updates still need review for native changes. Pinned so lockfile maintenance can't update it either.",
"matchPackageNames": ["@capacitor/**"],
"rangeStrategy": "pin",
"automerge": false
}
]
}
51 changes: 51 additions & 0 deletions src/components/SessionList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { fireEvent, screen } from '@testing-library/react';
import { useContext } from 'react';
import { describe, expect, it } from 'vitest';

import { AppContext } from '../data/AppContext';
import { getFilteredSchedule } from '../data/selectors';
import { breakfast, ionicTooling, makeState } from '../testing/fixtures';
import { renderWithState } from '../testing/render';

import SessionList from './SessionList';

// Shows the favorited session ids from the app state.
const Favorites = () => <output>{useContext(AppContext).state.data.favorites.join(',')}</output>;

const renderList = (favorites: number[]) => {
const state = makeState({ data: { favorites } });
return renderWithState(
<>
<SessionList schedule={getFilteredSchedule(state)} listType="all" hide={false} />
<Favorites />
</>,
{ state },
);
};

// The Favorite option revealed by swiping a session's item.
const favoriteOption = (container: HTMLElement, name: string) => {
const item = Array.from(container.querySelectorAll('ion-item-sliding')).find(
(sliding) => sliding.querySelector('h3')?.textContent === name,
)!;
return item.querySelector('ion-item-option') as HTMLElement;
};

describe('SessionList', () => {
it('favorites a session from its item', () => {
const { container } = renderList([]);

fireEvent.click(favoriteOption(container, 'Ionic Tooling'));

expect(screen.getByRole('status')).toHaveTextContent(String(ionicTooling.id));
});

it('does not favorite a session twice when it is already a favorite', () => {
const { container } = renderList([ionicTooling.id, breakfast.id]);

// This asks whether to remove the favorite instead, and leaves the favorites alone until it is answered.
fireEvent.click(favoriteOption(container, 'Ionic Tooling'));

expect(screen.getByRole('status')).toHaveTextContent(`${ionicTooling.id},${breakfast.id}`);
});
});
1 change: 0 additions & 1 deletion src/components/SessionListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const SessionListItem: React.FC<SessionListItemProps> = ({
};

const removeFavoriteSession = (title: string) => {
onAddFavorite(session.id);
onShowAlert(title, 'Would you like to remove this session from your favorites?', [
{
text: 'Cancel',
Expand Down
10 changes: 5 additions & 5 deletions src/components/ShareSocialFab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ const ShareSocialFab: React.FC = () => {
onDidDismiss={() => setShowLoading(false)}
/>
<IonFab slot="fixed" vertical="bottom" horizontal="end">
<IonFabButton>
<IonFabButton aria-label="Share">
<IonIcon icon={shareSocial} />
</IonFabButton>
<IonFabList side="top">
<IonFabButton color="vimeo" onClick={() => openSocial('Vimeo')}>
<IonFabButton color="vimeo" aria-label="Vimeo" onClick={() => openSocial('Vimeo')}>
<IonIcon icon={logoVimeo} />
</IonFabButton>
<IonFabButton color="instagram" onClick={() => openSocial('Instagram')}>
<IonFabButton color="instagram" aria-label="Instagram" onClick={() => openSocial('Instagram')}>
<IonIcon icon={logoInstagram} />
</IonFabButton>
<IonFabButton color="twitter" onClick={() => openSocial('Twitter')}>
<IonFabButton color="twitter" aria-label="Twitter" onClick={() => openSocial('Twitter')}>
<IonIcon icon={logoTwitter} />
</IonFabButton>
<IonFabButton color="facebook" onClick={() => openSocial('Facebook')}>
<IonFabButton color="facebook" aria-label="Facebook" onClick={() => openSocial('Facebook')}>
<IonIcon icon={logoFacebook} />
</IonFabButton>
</IonFabList>
Expand Down
Loading
Loading