diff --git a/.github/workflows/e2e-desktop-nightly-firefox.yml b/.github/workflows/e2e-desktop-nightly-firefox.yml index 818d2ca..9605d86 100644 --- a/.github/workflows/e2e-desktop-nightly-firefox.yml +++ b/.github/workflows/e2e-desktop-nightly-firefox.yml @@ -1,13 +1,13 @@ name: e2e-desktop-nightly-firefox concurrency: - group: e2e-desktop-nightly-firefox - cancel-in-progress: true + group: e2e-browserstack-production-account + cancel-in-progress: false on: schedule: - # run every day at 5:15am UTC - - cron: '15 5 * * *' + # run every day at 5:45am UTC + - cron: '45 5 * * *' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/e2e-desktop-nightly-safari.yml b/.github/workflows/e2e-desktop-nightly-safari.yml index 696a8a0..ca3bee9 100644 --- a/.github/workflows/e2e-desktop-nightly-safari.yml +++ b/.github/workflows/e2e-desktop-nightly-safari.yml @@ -1,13 +1,13 @@ name: e2e-desktop-nightly-safari concurrency: - group: e2e-desktop-nightly-safari - cancel-in-progress: true + group: e2e-browserstack-production-account + cancel-in-progress: false on: schedule: - # run every day at 5:30am UTC - - cron: '30 5 * * *' + # run every day at 6:30am UTC + - cron: '30 6 * * *' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/e2e-mobile-nightly-android.yml b/.github/workflows/e2e-mobile-nightly-android.yml index e1a84fe..5899f35 100644 --- a/.github/workflows/e2e-mobile-nightly-android.yml +++ b/.github/workflows/e2e-mobile-nightly-android.yml @@ -1,13 +1,13 @@ name: e2e-mobile-nightly-android concurrency: - group: e2e-mobile-nightly-android - cancel-in-progress: true + group: e2e-browserstack-production-account + cancel-in-progress: false on: schedule: - # run every day at 5:45am UTC - - cron: '45 5 * * *' + # run every day at 7:15am UTC + - cron: '15 7 * * *' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/e2e-nightly-desktop-chrome.yml b/.github/workflows/e2e-nightly-desktop-chrome.yml index f0180bb..ed66cd5 100644 --- a/.github/workflows/e2e-nightly-desktop-chrome.yml +++ b/.github/workflows/e2e-nightly-desktop-chrome.yml @@ -1,8 +1,8 @@ name: e2e-desktop-nightly-chrome concurrency: - group: e2e-desktop-nightly-chrome - cancel-in-progress: true + group: e2e-browserstack-production-account + cancel-in-progress: false on: schedule: diff --git a/.gitignore b/.gitignore index d1d2556..c173d68 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ screenshots/ # Temporary configuration generated by the BrowserStack Playwright SDK tests/browserstack/playwright-browserstack-sdk.config.ts tests/browserstack/playwright-browserstack-sdk.config.temp.json +tests/browserstack/browserstackSetupConfig.json # Temporary files *.tmp diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index e8cf1bd..5605d21 100644 --- a/tests/browserstack/README.md +++ b/tests/browserstack/README.md @@ -1,6 +1,6 @@ # Stormbox BrowserStack E2E Tests -This package contains Playwright tests for deployed Stormbox stage and production. Test actions and assertions run through the public Stormbox UI on your local machine or in BrowserStack. The folder-management suite also uses direct JMAP access from the Node test runner solely to remove test folders left by earlier interrupted runs. +This package contains Playwright tests for deployed Stormbox stage and production. Test actions and assertions run through the public Stormbox UI on your local machine or in BrowserStack. The folder-management and Contacts suites use direct JMAP access from the Node test runner solely to remove test data left by earlier interrupted runs. These tests are not for the local Stormbox stack. The local-stack integration tests live in `../e2e` and retain their own JMAP helper, database reads, local stack setup, and cache assertions. The BrowserStack JMAP helper connects only to a deployed stage or production Thundermail account using that dedicated test account's app password. diff --git a/tests/browserstack/helpers/jmap-client.ts b/tests/browserstack/helpers/jmap-client.ts index 375b3fd..8f34566 100644 --- a/tests/browserstack/helpers/jmap-client.ts +++ b/tests/browserstack/helpers/jmap-client.ts @@ -23,7 +23,9 @@ import { const CORE_CAPABILITY = 'urn:ietf:params:jmap:core'; const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail'; const SUBMISSION_CAPABILITY = 'urn:ietf:params:jmap:submission'; +const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts'; const REQUEST_TIMEOUT_MS = 30_000; +const CONTACT_PAGE_SIZE = 200; type JmapMethodCall = [string, Record, string]; type JmapMethodResponse = [string, Record, string]; @@ -39,11 +41,16 @@ interface JmapSession { interface JmapClient { accountId: string; + contactsAccountId?: string; apiUrl: string; authHeader: string; identityAccountId: string; } +interface JmapContactsClient extends JmapClient { + contactsAccountId: string; +} + interface JmapMailbox { id: string; name: string; @@ -208,6 +215,7 @@ async function connectJmap(): Promise { const client: JmapClient = { accountId, + contactsAccountId: session.primaryAccounts?.[CONTACTS_CAPABILITY], apiUrl, authHeader, identityAccountId: session.primaryAccounts?.[SUBMISSION_CAPABILITY] ?? accountId, @@ -216,6 +224,183 @@ async function connectJmap(): Promise { return client; } +async function connectContactsJmap(): Promise { + const client = await connectJmap(); + if (!client.contactsAccountId || client.contactsAccountId !== client.accountId + || client.identityAccountId !== client.accountId) { + throw new Error('JMAP primary contacts, mail, and identity accounts differ; refusing cleanup'); + } + return client as JmapContactsClient; +} + +interface JmapContactCard { + id: string; + name?: { full?: string } | null; +} + +interface JmapAddressBook { + id: string; + name: string; + isDefault: boolean; +} + +/** Page the complete card inventory and reject an inconsistent query snapshot. */ +async function listContactCardIds(client: JmapContactsClient, filter?: Record): Promise { + const ids: string[] = []; + let queryState: string | null = null; + let total: number | null = null; + + while (total == null || ids.length < total) { + const payload = await jmapRequest(client, [[ + 'ContactCard/query', + { + accountId: client.contactsAccountId, + ...(filter ? { filter } : {}), + position: ids.length, + limit: CONTACT_PAGE_SIZE, + calculateTotal: true, + }, + 'contactCards', + ]], [CORE_CAPABILITY, CONTACTS_CAPABILITY]); + const result = responseFor(payload, 'ContactCard/query'); + if (typeof result.queryState !== 'string' || !result.queryState + || !Number.isSafeInteger(result.total) || Number(result.total) < 0 + || result.position !== ids.length || !Array.isArray(result.ids) + || result.ids.length > CONTACT_PAGE_SIZE + || result.ids.some((id) => typeof id !== 'string')) { + throw new Error('ContactCard/query did not return a complete inventory page'); + } + if (queryState !== null && (queryState !== result.queryState || total !== result.total)) { + throw new Error('ContactCard/query changed while paging; refusing cleanup'); + } + queryState = result.queryState; + total = Number(result.total); + if (result.ids.length === 0 && ids.length < total) { + throw new Error('ContactCard/query stopped before the complete inventory was read'); + } + ids.push(...result.ids as string[]); + } + + if (ids.length !== total || new Set(ids).size !== ids.length) { + throw new Error('ContactCard/query returned an incomplete or duplicate inventory'); + } + return ids; +} + +async function listContactCards(client: JmapContactsClient): Promise { + const ids = await listContactCardIds(client); + const cards: JmapContactCard[] = []; + for (let offset = 0; offset < ids.length; offset += CONTACT_PAGE_SIZE) { + const pageIds = ids.slice(offset, offset + CONTACT_PAGE_SIZE); + const payload = await jmapRequest(client, [[ + 'ContactCard/get', + { accountId: client.contactsAccountId, ids: pageIds, properties: ['id', 'name'] }, + 'contactCardDetails', + ]], [CORE_CAPABILITY, CONTACTS_CAPABILITY]); + const result = responseFor(payload, 'ContactCard/get'); + if (!Array.isArray(result.list) || result.list.length !== pageIds.length + || (Array.isArray(result.notFound) && result.notFound.length > 0)) { + throw new Error('ContactCard/get did not return every queried card'); + } + for (const card of result.list) { + if (typeof card !== 'object' || card == null + || typeof card.id !== 'string' + || (card.name != null && (typeof card.name !== 'object' + || (card.name.full != null && typeof card.name.full !== 'string')))) { + throw new Error('ContactCard/get returned a malformed card'); + } + cards.push(card as JmapContactCard); + } + } + const expected = new Set(ids); + if (cards.length !== ids.length || cards.some((card) => !expected.delete(card.id)) + || expected.size !== 0) { + throw new Error('ContactCard/get returned a mismatched card inventory'); + } + return cards; +} + +/** Remove only named test cards, before test address books are inspected. */ +export async function deleteContactsByPrefix(prefix: string): Promise { + if (prefix !== 'E2E-Contact') { + throw new Error('Contact cleanup requires the E2E-Contact prefix'); + } + const client = await connectContactsJmap(); + const matching = (await listContactCards(client)) + .filter((card) => card.name?.full?.startsWith(prefix)); + for (let offset = 0; offset < matching.length; offset += CONTACT_PAGE_SIZE) { + const ids = matching.slice(offset, offset + CONTACT_PAGE_SIZE).map((card) => card.id); + const payload = await jmapRequest(client, [[ + 'ContactCard/set', + { accountId: client.contactsAccountId, destroy: ids }, + 'deleteContactCards', + ]], [CORE_CAPABILITY, CONTACTS_CAPABILITY]); + const result = responseFor(payload, 'ContactCard/set'); + const destroyed = new Set(Array.isArray(result.destroyed) ? result.destroyed : []); + if (result.notDestroyed && Object.keys(result.notDestroyed).length > 0 + || ids.some((id) => !destroyed.has(id))) { + throw new Error(`Could not delete test contacts: ${JSON.stringify(result.notDestroyed ?? {})}`); + } + } + if ((await listContactCards(client)).some((card) => card.name?.full?.startsWith(prefix))) { + throw new Error('JMAP cleanup left matching contacts'); + } + return matching.length; +} + +async function listAddressBooks(client: JmapContactsClient): Promise { + const payload = await jmapRequest(client, [[ + 'AddressBook/get', + { accountId: client.contactsAccountId, ids: null, properties: ['id', 'name', 'isDefault'] }, + 'addressBooks', + ]], [CORE_CAPABILITY, CONTACTS_CAPABILITY]); + const list = responseFor(payload, 'AddressBook/get').list; + if (!Array.isArray(list) || list.some((book) => + typeof book !== 'object' || book == null + || typeof book.id !== 'string' || typeof book.name !== 'string' + || typeof book.isDefault !== 'boolean')) { + throw new Error('AddressBook/get did not return valid address books'); + } + return list as JmapAddressBook[]; +} + +/** Refuse books containing other cards; never destroy their contents implicitly. */ +export async function deleteAddressBooksByPrefix(prefix: string): Promise { + if (prefix !== 'E2E-AddressBook') { + throw new Error('Address book cleanup requires the E2E-AddressBook prefix'); + } + const client = await connectContactsJmap(); + const matching = (await listAddressBooks(client)) + .filter((book) => book.name.startsWith(prefix)); + for (const book of matching) { + if (book.isDefault) { + throw new Error(`Refusing to delete default address book "${book.name}"`); + } + const cardIds = await listContactCardIds(client, { inAddressBook: book.id }); + if (cardIds.length > 0) { + throw new Error(`Refusing to delete test address book "${book.name}" containing unmatched cards`); + } + const payload = await jmapRequest(client, [[ + 'AddressBook/set', + { + accountId: client.contactsAccountId, + destroy: [book.id], + onDestroyRemoveContents: false, + }, + 'deleteAddressBook', + ]], [CORE_CAPABILITY, CONTACTS_CAPABILITY]); + const result = responseFor(payload, 'AddressBook/set'); + if (result.notDestroyed && Object.keys(result.notDestroyed).length > 0 + || !Array.isArray(result.destroyed) || !result.destroyed.includes(book.id)) { + throw new Error(`Could not delete test address book "${book.name}": ${JSON.stringify(result.notDestroyed ?? {})}`); + } + } + if ((await listAddressBooks(client)).some((book) => book.name.startsWith(prefix))) { + throw new Error('JMAP cleanup left matching address books'); + } + return matching.length; +} + /** Fetch the complete mailbox tree needed to delete children before parents. */ async function listMailboxes(client: JmapClient): Promise { const payload = await jmapRequest(client, [[ diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts new file mode 100644 index 0000000..873dd2e --- /dev/null +++ b/tests/browserstack/pages/contacts-page.ts @@ -0,0 +1,563 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { + ACCTS_OIDC_EMAIL, + PRIMARY_THUNDERMAIL_EMAIL, +} from '../const/constants'; +import { StormboxPage } from './stormbox-page'; + +export const CONTACT_PREFIX = 'E2E-Contact'; +export const BOOK_PREFIX = 'E2E-AddressBook'; + +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; +const GIF_BASE64 = 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + +export interface ContactCreateFields { + name: string; + homeEmail: string; + phone: string; + website: string; + birthday: string; + note: string; + organization: string; + department: string; + title: string; + role: string; +} + +export interface ContactEditFields { + name: string; + workEmail: string; + phone: string; + website: string; + weddingDate: string; + note: string; + title: string; +} + +export interface ExpectedContactDetails extends ContactCreateFields { + photo: 'png' | 'gif'; + workEmail?: string; + weddingDate?: string; + absent?: string[]; +} + +/** Contacts UI interactions for both the desktop panes and Android's single column. */ +export class ContactsPage { + private readonly page: Page; + private readonly stormbox: StormboxPage; + private readonly projectName: string; + private readonly onAndroid: boolean; + + readonly contactsView: Locator; + readonly sidebar: Locator; + readonly rail: Locator; + readonly list: Locator; + readonly listHeading: Locator; + readonly listNotice: Locator; + readonly contactForm: Locator; + readonly contactDetails: Locator; + readonly bookForm: Locator; + readonly bookDetails: Locator; + readonly confirmationDialog: Locator; + + constructor(page: Page, stormbox: StormboxPage, projectName: string) { + this.page = page; + this.stormbox = stormbox; + this.projectName = projectName; + this.onAndroid = projectName.toLowerCase().includes('android'); + this.contactsView = page.locator('.contacts'); + this.sidebar = page.locator('.sidebar-slot'); + this.rail = page.getByRole('navigation', { name: 'Address books' }); + this.list = page.locator('.directory-shell__list'); + this.listHeading = page.locator('.directory-list__header h2'); + this.listNotice = page.locator('.directory-list__notice'); + this.contactForm = page.locator('.contact-detail__editor'); + this.contactDetails = page.locator('.contact-detail__body'); + this.bookForm = page.locator('.address-book-detail__editor'); + this.bookDetails = page.locator('.address-book-detail__body'); + this.confirmationDialog = page.getByRole('alertdialog'); + } + + /** Android BrowserStack uses forced taps for visible remote touch controls. */ + private async tap(target: Locator): Promise { + await expect(target).toBeVisible(); + await target.click({ force: this.onAndroid }); + } + + contactRow(name: string): Locator { + return this.page.locator('.contacts__row').filter({ + has: this.page.locator('.directory-list__row-content .name') + .getByText(name, { exact: true }), + }); + } + + bookButton(name: string): Locator { + return this.page.locator('.contacts-rail__book').filter({ + has: this.page.locator('.contacts-rail__name') + .getByText(name, { exact: true }), + }); + } + + private detailValue(heading: string, label: string): Locator { + return this.contactDetails.locator('section').filter({ + has: this.page.getByRole('heading', { name: heading, exact: true }), + }).locator('dt').filter({ hasText: new RegExp(`^${label}$`) }) + .locator('xpath=following-sibling::dd[1]'); + } + + private resourceRows(kind: 'email' | 'phone' | 'website'): Locator { + // ContactDetailPane renders these resource fieldsets in this fixed order. + const section = { email: 0, phone: 1, website: 2 }[kind]; + return this.contactForm.locator('.contact-resource').nth(section) + .locator('.contact-resource__row'); + } + + private async chooseResourceLabel(row: Locator, kind: string, label: string): Promise { + const summary = row.getByLabel(new RegExp(`^Choose ${kind} label`)); + await this.tap(summary); + await this.tap(row.getByRole('menuitemradio', { name: label, exact: true })); + await expect(summary).toHaveAttribute('aria-label', new RegExp(`current label ${label}$`)); + } + + /** The filter change awaits the Contacts navigation guard before Vue applies it. */ + async filter(value: string): Promise { + await this.stormbox.quickFilter.fill(value); + if (value) { + await expect(this.stormbox.quickFilter).not.toHaveClass(/quick-filter__input--empty/); + } else { + await expect(this.stormbox.quickFilter).toHaveClass(/quick-filter__input--empty/); + } + // Android unmounts the list while showing a detail pane. + if (await this.list.isVisible()) { + await expect(this.page.locator('.directory-list__viewport')) + .toHaveAttribute('aria-busy', 'false'); + } + } + + private async formattedDate(isoDate: string): Promise { + // Read the browser's locale; UTC noon keeps the calendar day stable. + return this.page.evaluate((iso) => { + const [year, month, day] = iso.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day, 12)); + return new Intl.DateTimeFormat(undefined, { + day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC', + }).format(date); + }, isoDate); + } + + /** Check the signed-in OIDC email before opening Contacts. */ + async open(): Promise { + expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') + .toBeTruthy(); + await expect(this.stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); + await this.tap(this.stormbox.contactsSpaceButton); + await expect(this.contactsView).toBeVisible({ timeout: 30_000 }); + await expect(this.stormbox.quickFilter).toBeVisible(); + await this.filter(''); + } + + async reloadAndOpen(): Promise { + await this.page.reload(); + // Android has no prepared storage state, so check sign-in after reloading. + await this.stormbox.signInIfNeeded(this.projectName); + await this.open(); + } + + /** Android replaces the list with a detail pane; desktop keeps both visible. */ + private async openList(): Promise { + // Android's rail drawer covers the detail Back button until it is closed. + await this.closeRailOnPhone(); + const back = this.page.locator( + '.contact-detail__header button[aria-label="Back"], ' + + '.address-book-detail__header button[aria-label="Back"], ' + + '.trash-detail__header button[aria-label="Back"]', + ); + for (let attempt = 0; attempt < 3; attempt += 1) { + if (await this.list.isVisible()) return; + // A book detail can sit on top of a contact detail on Android. + await this.tap(back); + } + await expect(this.list).toBeVisible(); + } + + /** Android's translated rail is inert while hidden; isVisible alone is insufficient. */ + async openRail(): Promise { + if (await this.sidebar.getAttribute('aria-hidden') === 'false') return; + await this.tap(this.stormbox.showAddressBookListButton); + await expect(this.sidebar).toHaveAttribute('aria-hidden', 'false'); + await expect(this.rail).toBeVisible(); + } + + private async closeRailOnPhone(): Promise { + if (await this.page.locator('.directory-shell').getAttribute('data-layout') !== 'phone') { + return; + } + // The Android drawer covers the list until the space-rail toggle closes it. + const hide = this.page.getByRole('button', { name: 'Hide address book list' }); + if (await hide.isVisible()) { + await this.tap(hide); + await expect(this.sidebar).toHaveAttribute('aria-hidden', 'true'); + } + } + + private async selectRailBook(name: string): Promise { + await this.openList(); + await this.openRail(); + await this.tap(this.bookButton(name)); + await this.closeRailOnPhone(); + await expect(this.listHeading).toHaveText(name); + await expect(this.page.getByRole('listbox', { name, exact: true })) + .toHaveAttribute('aria-busy', 'false'); + } + + async displayAllContacts(): Promise { + await this.selectRailBook('All contacts'); + } + + async selectTrash(): Promise { + await this.selectRailBook('Trash'); + } + + async selectBook(name: string): Promise { + await this.selectRailBook(name); + } + + /** Check that the Primary identity has the configured address. */ + async assertExpectedPrimaryMailIdentityVisible(): Promise { + const expectedEmail = PRIMARY_THUNDERMAIL_EMAIL.trim(); + expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must contain the expected primary identity') + .toBeTruthy(); + await this.selectRailBook('Identities'); + await this.filter(expectedEmail); + const listbox = this.page.getByRole('listbox', { name: 'Identities', exact: true }); + const rows = listbox.locator('.contacts__row'); + const primaryIdentityRow = rows.filter({ + has: this.page.locator('.directory-list__primary-badge') + .getByText('Primary', { exact: true }), + }); + // Duplicate addresses can place the Primary identity outside the virtualized rows. + await listbox.evaluate((element) => { element.scrollTop = 0; }); + await expect(rows.first()).toHaveAttribute('data-index', '0'); + const totalRows = Number(await rows.first().getAttribute('aria-setsize')); + const rowHeight = await rows.first().evaluate((element) => element.getBoundingClientRect().height); + while (await primaryIdentityRow.count() === 0) { + const lastIndex = Number(await rows.last().getAttribute('data-index')); + if (lastIndex >= totalRows - 1) { + throw new Error(`No Primary identity appears for ${expectedEmail}`); + } + // Move at least one measured row even when the Android keyboard shortens the viewport. + await listbox.evaluate((element, minimumStep) => { + element.scrollTop += Math.max(minimumStep, Math.floor(element.clientHeight / 2)); + }, Math.ceil(rowHeight) + 1); + await expect.poll(async () => Number(await rows.last().getAttribute('data-index'))) + .toBeGreaterThan(lastIndex); + } + await expect(primaryIdentityRow).toHaveCount(1); + await expect(primaryIdentityRow).toBeVisible(); + await expect(primaryIdentityRow.locator('.directory-list__row-content .email')) + .toHaveText(expectedEmail); + await this.filter(''); + await this.displayAllContacts(); + } + + /** New contacts inherit the currently selected address book. */ + private async beginContact(name: string, email: string): Promise { + await this.openList(); + await this.openRail(); + await this.tap(this.stormbox.addContactButton); + // Android opens the form behind the rail drawer; desktop has a separate sidebar. + await this.closeRailOnPhone(); + await expect(this.contactForm).toBeVisible(); + await this.contactForm.getByRole('textbox', { name: 'Full or display name' }).fill(name); + await this.contactForm.getByRole('textbox', { name: 'Email addresses value' }).fill(email); + } + + private async saveContact(name: string): Promise { + await this.tap(this.contactForm.getByRole('button', { name: 'Save contact' })); + await expect(this.contactForm).toBeHidden({ timeout: 30_000 }); + await expect(this.page.locator('.contact-detail__display-name')).toHaveText(name); + } + + private async uploadPhoto(kind: 'png' | 'gif'): Promise { + // Tiny in-memory fixtures keep the upload independent of host file paths. + const base64 = kind === 'png' ? PNG_BASE64 : GIF_BASE64; + await this.contactForm.locator('.contact-detail__photo-input').setInputFiles({ + name: `contact.${kind}`, + mimeType: `image/${kind}`, + buffer: Buffer.from(base64, 'base64'), + }); + await expect(this.contactForm.locator('.contact-detail__photo-editor img')) + .toHaveAttribute('src', `data:image/${kind};base64,${base64}`); + } + + async createBasicContact(name: string, email: string): Promise { + await this.beginContact(name, email); + await this.saveContact(name); + } + + /** Fill the complete create form through its labeled controls. */ + async createDetailedContact(fields: ContactCreateFields): Promise { + await this.beginContact(fields.name, fields.homeEmail); + const home = this.resourceRows('email').first(); + await this.chooseResourceLabel(home, 'email', 'Home'); + await expect(home.getByRole('button', { name: 'Primary' })) + .toHaveAttribute('aria-pressed', 'true'); + + await this.tap(this.contactForm.getByRole('button', { name: 'Add phone' })); + const phone = this.resourceRows('phone').first(); + await phone.getByRole('textbox', { name: 'Phone numbers value' }).fill(fields.phone); + await this.chooseResourceLabel(phone, 'phone', 'Work'); + + await this.tap(this.contactForm.getByRole('button', { name: 'Add website' })); + const website = this.resourceRows('website').first(); + await website.getByRole('textbox', { name: 'Websites value' }).fill(fields.website); + await this.chooseResourceLabel(website, 'website', 'Personal'); + + await this.tap(this.contactForm.getByRole('button', { name: 'Add date' })); + await this.contactForm.getByRole('textbox', { name: 'Contact date' }) + .fill(fields.birthday); + await this.tap(this.contactForm.getByRole('button', { name: 'Add note' })); + await this.contactForm.getByRole('textbox', { name: 'Contact note' }).fill(fields.note); + await this.tap(this.contactForm.getByRole('button', { name: 'Add work' })); + const work = this.contactForm.locator('.contact-affiliations__card'); + await work.getByRole('textbox', { name: 'Organization' }).fill(fields.organization); + await work.getByRole('textbox', { name: 'Department' }).fill(fields.department); + await work.getByRole('textbox', { name: 'Job title' }).fill(fields.title); + await work.getByRole('textbox', { name: 'Role' }).fill(fields.role); + await this.uploadPhoto('png'); + await this.saveContact(fields.name); + } + + /** Edit the same card; the Home email, birthday, and other fields remain untouched. */ + async editDetailedContact(fields: ContactEditFields): Promise { + await this.tap(this.page.locator('.contact-detail').getByRole('button', { name: 'Edit' })); + await expect(this.contactForm).toBeVisible(); + await this.contactForm.getByRole('textbox', { name: 'Full or display name' }) + .fill(fields.name); + await this.tap(this.contactForm.getByRole('button', { name: 'Add email' })); + const workEmail = this.resourceRows('email').nth(1); + await workEmail.getByRole('textbox', { name: 'Email addresses value' }) + .fill(fields.workEmail); + await this.chooseResourceLabel(workEmail, 'email', 'Work'); + await this.resourceRows('phone').first() + .getByRole('textbox', { name: 'Phone numbers value' }).fill(fields.phone); + await this.resourceRows('website').first() + .getByRole('textbox', { name: 'Websites value' }).fill(fields.website); + await this.tap(this.contactForm.getByRole('button', { name: 'Add date' })); + const wedding = this.contactForm.locator('.contact-dates__row').nth(1); + await this.tap(wedding.getByLabel(/^Choose date kind/)); + await this.tap(wedding.getByRole('menuitemradio', { name: 'Wedding' })); + await wedding.getByRole('textbox', { name: 'Contact date' }).fill(fields.weddingDate); + await this.contactForm.getByRole('textbox', { name: 'Contact note' }).fill(fields.note); + await this.contactForm.getByRole('textbox', { name: 'Job title' }).fill(fields.title); + await this.uploadPhoto('gif'); + await this.saveContact(fields.name); + } + + /** Match values to their visible labels, including the email's Primary badge. */ + async expectDetailedContact(fields: ExpectedContactDetails): Promise { + await expect(this.page.locator('.contact-detail__display-name')).toHaveText(fields.name); + await expect(this.detailValue('Email addresses', 'Home')).toContainText(fields.homeEmail); + await expect(this.detailValue('Email addresses', 'Home')).toContainText('Primary'); + if (fields.workEmail) { + await expect(this.detailValue('Email addresses', 'Work')).toContainText(fields.workEmail); + await expect(this.detailValue('Email addresses', 'Work')).not.toContainText('Primary'); + } + await expect(this.detailValue('Phone numbers', 'Work')).toContainText(fields.phone); + await expect(this.detailValue('Websites', 'Personal')).toContainText(fields.website); + await expect(this.detailValue('Dates', 'Birthday')) + .toHaveText(await this.formattedDate(fields.birthday)); + if (fields.weddingDate) { + await expect(this.detailValue('Dates', 'Wedding')) + .toHaveText(await this.formattedDate(fields.weddingDate)); + } + for (const value of [fields.note, fields.organization, fields.department, + `Title: ${fields.title}`, `Role: ${fields.role}`]) { + await expect(this.contactDetails).toContainText(value); + } + for (const value of fields.absent ?? []) { + await expect(this.contactDetails).not.toContainText(value); + } + const base64 = fields.photo === 'png' ? PNG_BASE64 : GIF_BASE64; + await expect(this.page.locator('.contact-detail__avatar img')) + .toHaveAttribute('src', `data:image/${fields.photo};base64,${base64}`); + } + + async expectContactRow(name: string): Promise { + await expect(this.contactRow(name)).toBeVisible(); + } + + async expectContactAbsent(name: string): Promise { + await expect(this.contactRow(name)).toHaveCount(0); + } + + async openContact(name: string): Promise { + await this.tap(this.contactRow(name)); + await expect(this.page.locator('.contact-detail__display-name')).toHaveText(name); + } + + async deleteOpenContact(): Promise { + await this.tap(this.page.locator('.contact-detail').getByRole('button', { name: 'Delete' })); + // The row disappears optimistically; the notice follows the completed delete mutation. + await expect(this.listNotice).toHaveText('1 contact deleted.', { timeout: 60_000 }); + } + + async expectTrashedContact(name: string): Promise { + await expect(this.page.locator('.trash-detail')).toContainText(name); + } + + async openTrashedContact(name: string): Promise { + await this.tap(this.contactRow(name)); + await this.expectTrashedContact(name); + } + + async expectBookNotDefault(name: string): Promise { + await this.openRail(); + await expect(this.bookButton(name)).toBeVisible(); + await expect(this.bookButton(name).locator('.contacts-rail__badge')).toHaveCount(0); + } + + async expectBookAbsent(name: string): Promise { + await expect(this.bookButton(name)).toHaveCount(0); + } + + async createBook(name: string, description: string): Promise { + await this.openList(); + await this.openRail(); + await this.tap(this.page.getByRole('button', { name: 'Create address book' })); + // The Android rail stays over the new-book form until its drawer is closed. + await this.closeRailOnPhone(); + await this.bookForm.getByLabel('Name', { exact: true }).fill(name); + await this.bookForm.getByLabel(/Description/).fill(description); + await expect(this.bookForm.getByLabel('Set as default')).not.toBeChecked(); + await this.tap(this.bookForm.getByRole('button', { name: 'Save address book' })); + await expect(this.page.locator('.address-book-detail__display-name')).toHaveText(name); + await expect(this.bookDetails).toContainText(description); + } + + async editBook(name: string, renamed: string, description: string): Promise { + await this.selectBook(name); + await this.tap(this.page.locator('.directory-list__addressbook-actions') + .getByRole('button', { name: 'Edit address book' })); + await this.bookForm.getByLabel('Name', { exact: true }).fill(renamed); + await this.bookForm.getByLabel(/Description/).fill(description); + await expect(this.bookForm.getByLabel('Set as default')).not.toBeChecked(); + await this.tap(this.bookForm.getByRole('button', { name: 'Save address book' })); + await expect(this.page.locator('.address-book-detail__display-name')).toHaveText(renamed); + await expect(this.bookDetails).toContainText(description); + await this.openRail(); + await expect(this.bookButton(renamed)).toBeVisible(); + await this.expectBookAbsent(name); + } + + async expectContactInBook(name: string, book: string): Promise { + // Changing books clears the row selected behind the address-book detail pane. + // Returning also proves the contact remains listed in the renamed book. + await this.displayAllContacts(); + await this.selectBook(book); + await this.expectContactRow(name); + await this.openContact(name); + await expect(this.contactDetails).toContainText(book); + } + + async requestBookDelete(name: string): Promise { + await this.selectBook(name); + await this.tap(this.page.locator('.directory-list__addressbook-actions') + .getByRole('button', { name: 'Delete address book' })); + await expect(this.confirmationDialog).toBeVisible(); + } + + /** Check the dialog's contact inventory before committing a book deletion. */ + async expectBookDeleteImpact(onlyHere: number, shared: number): Promise { + const exclusivePhrase = onlyHere === 1 ? 'contact belongs' : 'contacts belong'; + const sharedPhrase = shared === 1 ? 'contact has' : 'contacts have'; + await expect(this.confirmationDialog) + .toContainText(`${onlyHere} ${exclusivePhrase} only to this address book`); + await expect(this.confirmationDialog) + .toContainText(`${shared} ${sharedPhrase} other address-book memberships`); + } + + async confirmBookDelete(): Promise { + await this.tap(this.confirmationDialog.getByRole('button', { name: 'Delete address book' })); + } + + /** Use Webmail UI to permanently remove matching Trash entries left by previously interrupted runs. */ + async purgeTestTrash(): Promise { + await this.selectTrash(); + await this.filter(CONTACT_PREFIX); + const rows = this.page.locator('.contacts__row').filter({ + has: this.page.locator('.directory-list__row-content .name') + .filter({ hasText: /^E2E-Contact/ }), + }); + while (await rows.count() > 0) { + const name = (await rows.first().locator('.name').textContent())?.trim(); + expect(name, 'Trash cleanup requires a named test contact').toBeTruthy(); + await this.openTrashedContact(name!); + await this.tap(this.page.locator('.trash-detail') + .getByRole('button', { name: 'Delete Forever' })); + await this.tap(this.confirmationDialog + .getByRole('button', { name: 'Delete forever' })); + await expect(this.listNotice).toHaveText('1 contact deleted forever.', { timeout: 60_000 }); + await expect(this.contactRow(name!)).toHaveCount(0); + await this.openList(); + } + await this.filter(''); + } + + /** UI cleanup deletes cards first, then confirms that each book is empty. */ + async cleanUpThroughUi(): Promise { + expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') + .toBeTruthy(); + + if (await this.confirmationDialog.isVisible()) { + await this.tap(this.confirmationDialog.getByRole('button', { name: 'Cancel' })); + } + if (await this.contactForm.isVisible()) { + await this.tap(this.contactForm.getByRole('button', { name: 'Cancel' })); + } else if (await this.bookForm.isVisible()) { + await this.tap(this.bookForm.getByRole('button', { name: 'Cancel' })); + } + + await this.open(); + await this.assertExpectedPrimaryMailIdentityVisible(); + await this.filter(CONTACT_PREFIX); + + const rows = this.page.locator('.contacts__row').filter({ + has: this.page.locator('.directory-list__row-content .name') + .filter({ hasText: /^E2E-Contact/ }), + }); + + while (await rows.count() > 0) { + const name = (await rows.first().locator('.name').textContent())?.trim(); + expect(name, 'Contact cleanup requires a named test contact').toBeTruthy(); + // Keep one matching row so Android returns to the list after deletion. + // With another match, the next detail replaces the list and hides its notice. + await this.filter(name!); + await expect(this.contactRow(name!)).toHaveCount(1); + await this.openContact(name!); + await this.deleteOpenContact(); + await this.expectContactAbsent(name!); + await this.openList(); + await this.filter(CONTACT_PREFIX); + } + await this.purgeTestTrash(); + await this.openRail(); + + const books = await this.page.locator('.contacts-rail__name').allTextContents(); + + for (const name of books.filter((value) => value.startsWith(BOOK_PREFIX))) { + await this.selectBook(name); + await expect( + this.bookButton(name).locator('.contacts-rail__count'), + `Refusing UI cleanup of nonempty address book "${name}"`, + ).toHaveText('0'); + await expect(this.page.locator('.contacts__row')).toHaveCount(0); + await this.requestBookDelete(name); + // This dialog inventories the server again, after the rail's cached count. + await this.expectBookDeleteImpact(0, 0); + await this.confirmBookDelete(); + await this.expectBookAbsent(name); + } + } +} diff --git a/tests/browserstack/playwright.config.ts b/tests/browserstack/playwright.config.ts index 98053ec..bbd17ed 100644 --- a/tests/browserstack/playwright.config.ts +++ b/tests/browserstack/playwright.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ // Global timeout: Playwright will timeout if the entire session (includes all test runs) exceeds this. // Must take into account running on mulitple browsers (and BrowserStack is much slower too!). Odds are the // tests will time out at the locator/test level first anyway; but there is no default so best to specify - globalTimeout: 10 * 60 * 1000, + globalTimeout: 30 * 60 * 1000, // Individual test timeout - a single test will time out if it is still running after this time (ms) timeout: 5 * 60 * 1000, // 5 minutes /* Reporter to use. See https://playwright.dev/docs/test-reporters */ diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts new file mode 100644 index 0000000..3e6910b --- /dev/null +++ b/tests/browserstack/tests/contacts.spec.ts @@ -0,0 +1,172 @@ +import { randomUUID } from 'node:crypto'; + +import { test } from '@playwright/test'; + +import { + PLAYWRIGHT_TAG_DESKTOP, + PLAYWRIGHT_TAG_MOBILE, +} from '../const/constants'; +import { + deleteAddressBooksByPrefix, + deleteContactsByPrefix, +} from '../helpers/jmap-client'; +import { + BOOK_PREFIX, + CONTACT_PREFIX, + ContactsPage, + type ContactCreateFields, + type ContactEditFields, +} from '../pages/contacts-page'; +import { StormboxPage } from '../pages/stormbox-page'; + +function uniqueSuffix(): string { + return `${Date.now()}-${randomUUID().slice(0, 8)}`; +} + +test.describe('stormbox contacts and address books', { + tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE], +}, () => { + let contacts: ContactsPage; + let signedIn = false; + + test.beforeEach(async ({ page }, testInfo) => { + signedIn = false; + const stormbox = new StormboxPage(page); + contacts = new ContactsPage(page, stormbox, testInfo.project.name); + + // Clear leftovers from interrupted runs before each case. Sweep cards before books; + // BrowserStack lanes sharing this account must not run at the same time. + await deleteContactsByPrefix(CONTACT_PREFIX); + await deleteAddressBooksByPrefix(BOOK_PREFIX); + + const missing = await stormbox.missingRequiredBrowserFeatures(); + test.skip(missing.length > 0, + `Stormbox cannot run in this browser. Missing: ${missing.join(', ')}.`); + + await stormbox.navigate(); + + // Android signs in through the UI; desktop projects use their prepared auth state. + await stormbox.signInIfNeeded(testInfo.project.name); + signedIn = true; + await contacts.open(); + await contacts.assertExpectedPrimaryMailIdentityVisible(); + + // The JMAP card sweep leaves recoverable Contacts Trash entries behind. + await contacts.purgeTestTrash(); + await contacts.displayAllContacts(); + }); + + test.afterEach(async () => { + // UI cleanup covers artifacts from the current case, including Trash. + if (signedIn) await contacts.cleanUpThroughUi(); + }); + + test('creates and edits a contact with its details and photo', async ({}, testInfo) => { + testInfo.setTimeout(8 * 60 * 1000); + const suffix = uniqueSuffix(); + const initial: ContactCreateFields = { + name: `${CONTACT_PREFIX}-${suffix}-CreateEdit`, + homeEmail: `home-${suffix}@example.com`, + phone: '+15550101', + website: `https://example.com/${suffix}/personal`, + birthday: '1985-07-13', + note: `Original contact note ${suffix}`, + organization: 'Example Labs', + department: 'Research', + title: 'Engineer', + role: 'Contributor', + }; + const edited: ContactEditFields = { + name: `${initial.name}-Renamed`, + workEmail: `work-${suffix}@example.com`, + phone: '+15550202', + website: `https://example.com/${suffix}/updated`, + weddingDate: '2010-06-15', + note: `Updated contact note ${suffix}`, + title: 'Principal Engineer', + }; + + await test.step('create and verify all contact details', async () => { + await contacts.createDetailedContact(initial); + await contacts.expectDetailedContact({ ...initial, photo: 'png' }); + }); + + await test.step('edit the same contact and replace its photo', async () => { + await contacts.editDetailedContact(edited); + }); + + await test.step('reload and verify changed and retained details', async () => { + await contacts.reloadAndOpen(); + await contacts.displayAllContacts(); + await contacts.filter(edited.name); + await contacts.openContact(edited.name); + await contacts.expectDetailedContact({ + ...initial, + ...edited, + photo: 'gif', + absent: [initial.phone, initial.website, initial.note], + }); + }); + }); + + test('deletes a contact', async () => { + const suffix = uniqueSuffix(); + const name = `${CONTACT_PREFIX}-${suffix}-Delete`; + const email = `delete-${suffix}@example.com`; + + await contacts.createBasicContact(name, email); + await contacts.displayAllContacts(); + await contacts.filter(name); + await contacts.expectContactRow(name); + await contacts.openContact(name); + await contacts.deleteOpenContact(); + await contacts.expectContactAbsent(name); + + // A contact delete moves this independent card to Trash. + await contacts.selectTrash(); + await contacts.expectContactRow(name); + await contacts.openTrashedContact(name); + }); + + test('creates and edits an address book containing a contact', async () => { + const suffix = uniqueSuffix(); + const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; + const renamed = `${name}-Renamed`; + const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; + await contacts.createBook(name, 'Initial BrowserStack book'); + await contacts.expectBookNotDefault(name); + // Creating in the selected book gives the card a single book membership. + await contacts.selectBook(name); + await contacts.createBasicContact(contactName, `book-${suffix}@example.com`); + await contacts.selectBook(name); + await contacts.expectContactRow(contactName); + + await contacts.editBook(name, renamed, 'Updated BrowserStack book'); + await contacts.expectBookNotDefault(renamed); + await contacts.expectContactInBook(contactName, renamed); + }); + + test('deletes an address book and its sole contact', async () => { + const suffix = uniqueSuffix(); + const name = `${BOOK_PREFIX}-${suffix}-Delete`; + const contactName = `${CONTACT_PREFIX}-${suffix}-DeletedBookMember`; + + await contacts.createBook(name, 'Book to delete'); + // The confirmation inventory must report this card as exclusive to the book. + await contacts.selectBook(name); + await contacts.createBasicContact(contactName, `book-delete-${suffix}@example.com`); + await contacts.selectBook(name); + await contacts.expectContactRow(contactName); + await contacts.requestBookDelete(name); + await contacts.expectBookDeleteImpact(1, 0); + await contacts.confirmBookDelete(); + await contacts.expectBookAbsent(name); + + // Deleting an exclusive book card is permanent; it must not enter Trash. + await contacts.displayAllContacts(); + await contacts.filter(contactName); + await contacts.expectContactAbsent(contactName); + await contacts.selectTrash(); + await contacts.expectContactAbsent(contactName); + }); +});