From ad472ac0dca4da03a9829afa872fc01e76c65fb7 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 29 Sep 2026 15:28:56 -0400 Subject: [PATCH 01/15] Add BrowserStack Contacts and address book E2E coverage. --- .../workflows/e2e-desktop-nightly-firefox.yml | 8 +- .../workflows/e2e-desktop-nightly-safari.yml | 8 +- .../workflows/e2e-mobile-nightly-android.yml | 8 +- .../workflows/e2e-nightly-desktop-chrome.yml | 4 +- tests/browserstack/README.md | 15 +- tests/browserstack/helpers/jmap-client.ts | 185 +++++++ tests/browserstack/playwright.config.ts | 2 +- tests/browserstack/tests/contacts.spec.ts | 496 ++++++++++++++++++ 8 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 tests/browserstack/tests/contacts.spec.ts 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/tests/browserstack/README.md b/tests/browserstack/README.md index e8cf1bd..1a27e96 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. @@ -85,3 +85,16 @@ npm run e2e:browserstack:mobile:android:chrome ``` Desktop runs authenticate once in `tests/auth.desktop.ts` and save `test-results/.auth/user.json`. Android mobile runs sign in through the UI for each test because BrowserStack mobile contexts cannot use the saved desktop auth state. + +## Contacts and address books + +The full desktop and mobile suites include four independent Contacts cases: + +| Case | UI flow | +| --- | --- | +| Create/edit contact | Create an `E2E-Contact` with a primary Home email, Work phone, Personal website, birthday, note, Work affiliation, and PNG photo. Edit its name, add a Work email and wedding date, change the phone, website, note, and title, and replace the photo with a GIF. Reload and check changed and preserved details. | +| Delete contact | Create a separate named contact with an email, remove it from All Contacts, and check it appears in Trash. Cleanup uses Delete Forever. | +| Create/edit address book | Create an `E2E-AddressBook` with a description without making it the default, add a contact inside it, rename the book and edit its description, and check the contact remains in it while the default book stays the same. | +| Delete address book | Create another book and sole contact, check the delete dialog reports one exclusive contact, then delete the book. Check the contact is absent from All Contacts and Trash. | + +Each case uses a unique suffix. Before each test, direct JMAP cleanup deletes matching cards, then empty matching books, and the UI purges matching Trash entries after sign-in. After each test, the UI removes remaining test data. Runs using the same test account must not overlap because they sweep the shared `E2E-Contact` and `E2E-AddressBook` prefixes. The four production workflows share a concurrency group and start at 05:00, 05:45, 06:30, and 07:15 UTC. Run manual BrowserStack sessions one at a time, outside those nightly jobs. 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/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..964126e --- /dev/null +++ b/tests/browserstack/tests/contacts.spec.ts @@ -0,0 +1,496 @@ +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Locator, type Page } from '@playwright/test'; + +import { + ACCTS_OIDC_EMAIL, + PLAYWRIGHT_TAG_DESKTOP, + PLAYWRIGHT_TAG_MOBILE, + PRIMARY_THUNDERMAIL_EMAIL, +} from '../const/constants'; +import { + deleteAddressBooksByPrefix, + deleteContactsByPrefix, +} from '../helpers/jmap-client'; +import { StormboxPage } from '../pages/stormbox-page'; + +const CONTACT_PREFIX = 'E2E-Contact'; +const BOOK_PREFIX = 'E2E-AddressBook'; +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; +const GIF_BASE64 = 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + +function uniqueSuffix(): string { + return `${Date.now()}-${randomUUID().slice(0, 8)}`; +} + +function contactRow(page: Page, name: string): Locator { + return page.locator('.contacts__row').filter({ + has: page.locator('.directory-list__row-content .name').getByText(name, { exact: true }), + }); +} + +function bookButton(page: Page, name: string): Locator { + return page.locator('.contacts-rail__book').filter({ + has: page.locator('.contacts-rail__name').getByText(name, { exact: true }), + }); +} + +function detailSection(page: Page, heading: string): Locator { + return page.locator('.contact-detail__body section').filter({ + has: page.getByRole('heading', { name: heading, exact: true }), + }); +} + +function detailValueForLabel(page: Page, heading: string, label: string): Locator { + return detailSection(page, heading) + .locator('dt').filter({ hasText: new RegExp(`^${label}$`) }) + .locator('xpath=following-sibling::dd[1]'); +} + +async function setContactFilter(stormbox: StormboxPage, value: string): Promise { + await stormbox.quickFilter.fill(value); + if (value) { + await expect(stormbox.quickFilter).not.toHaveClass(/quick-filter__input--empty/); + } else { + await expect(stormbox.quickFilter).toHaveClass(/quick-filter__input--empty/); + } +} + +async function formattedDate(page: Page, isoDate: string): Promise { + return 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); +} + +async function openContacts(page: Page, stormbox: StormboxPage): Promise { + expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') + .toBeTruthy(); + await expect(stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); + await stormbox.contactsSpaceButton.click(); + await expect(page.locator('.contacts')).toBeVisible({ timeout: 30_000 }); + await expect(stormbox.quickFilter).toBeVisible(); + await setContactFilter(stormbox, ''); +} + +async function openList(page: Page): Promise { + const back = 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 page.locator('.directory-shell__list').isVisible()) return; + await back.click(); + } + await expect(page.locator('.directory-shell__list')).toBeVisible(); +} + +async function openRail(page: Page, stormbox: StormboxPage): Promise { + const sidebar = page.locator('.sidebar-slot'); + const rail = page.getByRole('navigation', { name: 'Address books' }); + if (await sidebar.getAttribute('aria-hidden') === 'false') return; + await stormbox.showAddressBookListButton.click(); + await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); + await expect(rail).toBeVisible(); +} + +async function closeRailOnPhone(page: Page): Promise { + if (await page.locator('.directory-shell').getAttribute('data-layout') !== 'phone') return; + const hide = page.getByRole('button', { name: 'Hide address book list' }); + if (await hide.isVisible()) { + await hide.click(); + await expect(page.locator('.sidebar-slot')).toHaveAttribute('aria-hidden', 'true'); + } +} + +async function selectAllContacts(page: Page, stormbox: StormboxPage): Promise { + await openList(page); + await openRail(page, stormbox); + await bookButton(page, 'All contacts').click(); + await closeRailOnPhone(page); + await expect(page.locator('.directory-list__header h2')).toHaveText('All contacts'); + await expect(page.getByRole('listbox', { name: 'All contacts' })) + .toHaveAttribute('aria-busy', 'false'); +} + +async function selectTrash(page: Page, stormbox: StormboxPage): Promise { + await openList(page); + await openRail(page, stormbox); + await bookButton(page, 'Trash').click(); + await closeRailOnPhone(page); + await expect(page.locator('.directory-list__header h2')).toHaveText('Trash'); + await expect(page.getByRole('listbox', { name: 'Trash', exact: true })) + .toHaveAttribute('aria-busy', 'false'); +} + +async function selectBook(page: Page, stormbox: StormboxPage, name: string): Promise { + await openList(page); + await openRail(page, stormbox); + await bookButton(page, name).click(); + await closeRailOnPhone(page); + await expect(page.locator('.directory-list__header h2')).toHaveText(name); + await expect(page.getByRole('listbox', { name, exact: true })) + .toHaveAttribute('aria-busy', 'false'); +} + +async function assertContactAccount(page: Page, stormbox: StormboxPage): Promise { + const expectedEmail = PRIMARY_THUNDERMAIL_EMAIL.trim(); + expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must identify the BrowserStack JMAP account') + .toBeTruthy(); + await openList(page); + await openRail(page, stormbox); + await bookButton(page, 'Identities').click(); + await closeRailOnPhone(page); + await expect(page.locator('.directory-list__header h2')).toHaveText('Identities'); + await expect(page.getByRole('listbox', { name: 'Identities' })) + .toHaveAttribute('aria-busy', 'false'); + await setContactFilter(stormbox, expectedEmail); + await expect(page.locator('.contacts__row .email').getByText(expectedEmail, { exact: true })) + .toBeVisible(); + await setContactFilter(stormbox, ''); + await selectAllContacts(page, stormbox); +} + +async function createContact( + page: Page, + stormbox: StormboxPage, + name: string, + email: string, +): Promise { + await openList(page); + await openRail(page, stormbox); + await stormbox.addContactButton.click(); + const form = page.locator('.contact-detail__editor'); + await expect(form).toBeVisible(); + await form.getByRole('textbox', { name: 'Full or display name' }).fill(name); + await form.getByRole('textbox', { name: 'Email addresses value' }).fill(email); + return form; +} + +async function chooseResourceLabel(row: Locator, kind: string, label: string): Promise { + await row.getByLabel(new RegExp(`^Choose ${kind} label`)).click(); + await row.getByRole('menuitemradio', { name: label, exact: true }).click(); + await expect(row.getByLabel(new RegExp(`^Choose ${kind} label`))) + .toHaveAttribute('aria-label', new RegExp(`current label ${label}$`)); +} + +async function saveContact(page: Page, form: Locator, name: string): Promise { + await form.getByRole('button', { name: 'Save contact' }).click(); + await expect(page.locator('.contact-detail__editor')).toBeHidden({ timeout: 30_000 }); + await expect(page.locator('.contact-detail__display-name')).toHaveText(name); +} + +async function createBook( + page: Page, + stormbox: StormboxPage, + name: string, + description: string, +): Promise { + await openList(page); + await openRail(page, stormbox); + await page.getByRole('button', { name: 'Create address book' }).click(); + const form = page.locator('.address-book-detail__editor'); + await form.getByLabel('Name', { exact: true }).fill(name); + await form.getByLabel(/Description/).fill(description); + await expect(form.getByLabel('Set as default')).not.toBeChecked(); + await form.getByRole('button', { name: 'Save address book' }).click(); + await expect(page.locator('.address-book-detail__display-name')).toHaveText(name); + await expect(page.locator('.address-book-detail__body')).toContainText(description); +} + +async function deleteBook(page: Page, stormbox: StormboxPage, name: string): Promise { + await selectBook(page, stormbox, name); + await page.locator('.directory-list__addressbook-actions') + .getByRole('button', { name: 'Delete address book' }).click(); + return page.getByRole('alertdialog'); +} + +async function purgeTestTrash(page: Page, stormbox: StormboxPage): Promise { + await selectTrash(page, stormbox); + await setContactFilter(stormbox, CONTACT_PREFIX); + const rows = page.locator('.contacts__row').filter({ + has: page.locator('.directory-list__row-content .name') + .filter({ hasText: /^E2E-Contact/ }), + }); + while (await rows.count() > 0) { + const name = await rows.first().locator('.name').textContent(); + await rows.first().click(); + await page.locator('.trash-detail').getByRole('button', { name: 'Delete Forever' }).click(); + await page.getByRole('alertdialog') + .getByRole('button', { name: 'Delete forever' }).click(); + await expect(page.locator('.contacts__row').filter({ + has: page.locator('.directory-list__row-content .name') + .getByText(name ?? '', { exact: true }), + })).toHaveCount(0); + await openList(page); + } + await setContactFilter(stormbox, ''); +} + +async function cleanUpThroughUi(page: Page, stormbox: StormboxPage): Promise { + expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') + .toBeTruthy(); + await expect(stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); + const dialog = page.getByRole('alertdialog'); + if (await dialog.isVisible()) { + await dialog.getByRole('button', { name: 'Cancel' }).click(); + } + const contactEditor = page.locator('.contact-detail__editor'); + const bookEditor = page.locator('.address-book-detail__editor'); + if (await contactEditor.isVisible()) { + await contactEditor.getByRole('button', { name: 'Cancel' }).click(); + } else if (await bookEditor.isVisible()) { + await bookEditor.getByRole('button', { name: 'Cancel' }).click(); + } + await openContacts(page, stormbox); + await assertContactAccount(page, stormbox); + await setContactFilter(stormbox, CONTACT_PREFIX); + const rows = page.locator('.contacts__row').filter({ + has: page.locator('.directory-list__row-content .name') + .filter({ hasText: /^E2E-Contact/ }), + }); + while (await rows.count() > 0) { + const name = await rows.first().locator('.name').textContent(); + await rows.first().click(); + await page.locator('.contact-detail').getByRole('button', { name: 'Delete' }).click(); + await expect(contactRow(page, name ?? '')).toHaveCount(0); + await openList(page); + } + await purgeTestTrash(page, stormbox); + + await openRail(page, stormbox); + const books = await page.locator('.contacts-rail__name').allTextContents(); + for (const name of books.filter((value) => value.startsWith(BOOK_PREFIX))) { + await selectBook(page, stormbox, name); + await expect( + bookButton(page, name).locator('.contacts-rail__count'), + `Refusing UI cleanup of nonempty address book "${name}"`, + ).toHaveText('0'); + await expect(page.locator('.contacts__row')).toHaveCount(0); + const confirmation = await deleteBook(page, stormbox, name); + await expect(confirmation).toContainText('0 contacts belong only to this address book'); + await expect(confirmation).toContainText('0 contacts have other address-book memberships'); + await confirmation.getByRole('button', { name: 'Delete address book' }).click(); + await expect(bookButton(page, name)).toHaveCount(0); + } +} + +test.describe('stormbox contacts and address books', { + tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE], +}, () => { + let stormbox: StormboxPage; + let signedIn = false; + + test.beforeEach(async ({ page }, testInfo) => { + signedIn = false; + stormbox = new StormboxPage(page); + 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(); + await stormbox.signInIfNeeded(testInfo.project.name); + signedIn = true; + await openContacts(page, stormbox); + await assertContactAccount(page, stormbox); + await purgeTestTrash(page, stormbox); + await selectAllContacts(page, stormbox); + }); + + test.afterEach(async ({ page }) => { + if (signedIn) await cleanUpThroughUi(page, stormbox); + }); + + test('creates and edits a contact with its details and photo', async ({ page }, testInfo) => { + testInfo.setTimeout(8 * 60 * 1000); + const suffix = uniqueSuffix(); + const originalName = `${CONTACT_PREFIX}-${suffix}-CreateEdit`; + const renamed = `${originalName}-Renamed`; + const homeEmail = `home-${suffix}@example.com`; + const workEmail = `work-${suffix}@example.com`; + const website = `https://example.com/${suffix}/personal`; + const newWebsite = `https://example.com/${suffix}/updated`; + const originalNote = `Original contact note ${suffix}`; + const updatedNote = `Updated contact note ${suffix}`; + const birthday = await formattedDate(page, '1985-07-13'); + const weddingDate = await formattedDate(page, '2010-06-15'); + + const form = await createContact(page, stormbox, originalName, homeEmail); + await chooseResourceLabel(form.locator('.contact-resource__row').nth(0), 'email', 'Home'); + await expect(form.locator('.contact-resource__row').nth(0) + .getByRole('button', { name: 'Primary' })).toHaveAttribute('aria-pressed', 'true'); + await form.getByRole('button', { name: 'Add phone' }).click(); + const phone = form.locator('.contact-resource__row').nth(1); + await phone.getByRole('textbox', { name: 'Phone numbers value' }).fill('+15550101'); + await chooseResourceLabel(phone, 'phone', 'Work'); + await form.getByRole('button', { name: 'Add website' }).click(); + const link = form.locator('.contact-resource__row').nth(2); + await link.getByRole('textbox', { name: 'Websites value' }).fill(website); + await chooseResourceLabel(link, 'website', 'Personal'); + await form.getByRole('button', { name: 'Add date' }).click(); + await form.getByRole('textbox', { name: 'Contact date' }).fill('1985-07-13'); + await form.getByRole('button', { name: 'Add note' }).click(); + await form.getByRole('textbox', { name: 'Contact note' }).fill(originalNote); + await form.getByRole('button', { name: 'Add work' }).click(); + const work = form.locator('.contact-affiliations__card'); + await work.getByRole('textbox', { name: 'Organization' }).fill('Example Labs'); + await work.getByRole('textbox', { name: 'Department' }).fill('Research'); + await work.getByRole('textbox', { name: 'Job title' }).fill('Engineer'); + await work.getByRole('textbox', { name: 'Role' }).fill('Contributor'); + await form.locator('.contact-detail__photo-input').setInputFiles({ + name: 'contact.png', mimeType: 'image/png', buffer: Buffer.from(PNG_BASE64, 'base64'), + }); + await expect(form.locator('.contact-detail__photo-editor img')) + .toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`); + await saveContact(page, form, originalName); + + const details = page.locator('.contact-detail__body'); + await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText(homeEmail); + await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText('Primary'); + await expect(detailValueForLabel(page, 'Phone numbers', 'Work')).toContainText('+15550101'); + await expect(detailValueForLabel(page, 'Websites', 'Personal')).toContainText(website); + await expect(detailValueForLabel(page, 'Dates', 'Birthday')).toHaveText(birthday); + await expect(details).toContainText(originalNote); + await expect(details).toContainText('Example Labs'); + await expect(details).toContainText('Research'); + await expect(details).toContainText('Title: Engineer'); + await expect(details).toContainText('Role: Contributor'); + await expect(page.locator('.contact-detail__avatar img')) + .toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`); + + await page.locator('.contact-detail').getByRole('button', { name: 'Edit' }).click(); + const edit = page.locator('.contact-detail__editor'); + await edit.getByRole('textbox', { name: 'Full or display name' }).fill(renamed); + await edit.getByRole('button', { name: 'Add email' }).click(); + const workEmailRow = edit.locator('.contact-resource__row').nth(1); + await workEmailRow.getByRole('textbox', { name: 'Email addresses value' }).fill(workEmail); + await chooseResourceLabel(workEmailRow, 'email', 'Work'); + await edit.locator('.contact-resource__row').nth(2) + .getByRole('textbox', { name: 'Phone numbers value' }).fill('+15550202'); + await edit.locator('.contact-resource__row').nth(3) + .getByRole('textbox', { name: 'Websites value' }).fill(newWebsite); + await edit.getByRole('button', { name: 'Add date' }).click(); + const wedding = edit.locator('.contact-dates__row').nth(1); + await wedding.getByLabel(/^Choose date kind/).click(); + await wedding.getByRole('menuitemradio', { name: 'Wedding' }).click(); + await wedding.getByRole('textbox', { name: 'Contact date' }).fill('2010-06-15'); + await edit.getByRole('textbox', { name: 'Contact note' }).fill(updatedNote); + await edit.getByRole('textbox', { name: 'Job title' }).fill('Principal Engineer'); + await edit.locator('.contact-detail__photo-input').setInputFiles({ + name: 'contact.gif', mimeType: 'image/gif', buffer: Buffer.from(GIF_BASE64, 'base64'), + }); + await expect(edit.locator('.contact-detail__photo-editor img')) + .toHaveAttribute('src', `data:image/gif;base64,${GIF_BASE64}`); + await saveContact(page, edit, renamed); + + await page.reload(); + await stormbox.signInIfNeeded(testInfo.project.name); + await openContacts(page, stormbox); + await selectAllContacts(page, stormbox); + await setContactFilter(stormbox, renamed); + await contactRow(page, renamed).click(); + await expect(page.locator('.contact-detail__display-name')).toHaveText(renamed); + await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText(homeEmail); + await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText('Primary'); + await expect(detailValueForLabel(page, 'Email addresses', 'Work')).toContainText(workEmail); + await expect(detailValueForLabel(page, 'Email addresses', 'Work')).not.toContainText('Primary'); + await expect(detailValueForLabel(page, 'Phone numbers', 'Work')).toContainText('+15550202'); + await expect(detailValueForLabel(page, 'Websites', 'Personal')).toContainText(newWebsite); + await expect(detailValueForLabel(page, 'Dates', 'Birthday')).toHaveText(birthday); + await expect(detailValueForLabel(page, 'Dates', 'Wedding')).toHaveText(weddingDate); + await expect(details).toContainText(updatedNote); + await expect(details).toContainText('Title: Principal Engineer'); + await expect(details).toContainText('Example Labs'); + await expect(details).toContainText('Research'); + await expect(details).toContainText('Role: Contributor'); + await expect(details).not.toContainText('+15550101'); + await expect(details).not.toContainText(website); + await expect(details).not.toContainText(originalNote); + await expect(page.locator('.contact-detail__avatar img')) + .toHaveAttribute('src', `data:image/gif;base64,${GIF_BASE64}`); + }); + + test('deletes a separate contact into Trash', async ({ page }) => { + const suffix = uniqueSuffix(); + const name = `${CONTACT_PREFIX}-${suffix}-Delete`; + const email = `delete-${suffix}@example.com`; + const form = await createContact(page, stormbox, name, email); + await saveContact(page, form, name); + await selectAllContacts(page, stormbox); + await setContactFilter(stormbox, name); + await expect(contactRow(page, name)).toBeVisible(); + await contactRow(page, name).click(); + await page.locator('.contact-detail').getByRole('button', { name: 'Delete' }).click(); + await expect(contactRow(page, name)).toHaveCount(0); + await selectTrash(page, stormbox); + await expect(contactRow(page, name)).toBeVisible(); + await contactRow(page, name).click(); + await expect(page.locator('.trash-detail')).toContainText(name); + }); + + test('creates and edits an address book containing a contact', async ({ page }) => { + const suffix = uniqueSuffix(); + const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; + const renamed = `${name}-Renamed`; + const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; + const email = `book-${suffix}@example.com`; + await openRail(page, stormbox); + const defaultName = await page.locator('.contacts-rail__book') + .filter({ has: page.locator('.contacts-rail__badge') }) + .locator('.contacts-rail__name').textContent(); + expect(defaultName).toBeTruthy(); + + await createBook(page, stormbox, name, 'Initial BrowserStack book'); + await expect(bookButton(page, name)).not.toContainText('Personal'); + const form = await createContact(page, stormbox, contactName, email); + await saveContact(page, form, contactName); + await selectBook(page, stormbox, name); + await expect(contactRow(page, contactName)).toBeVisible(); + await page.locator('.directory-list__addressbook-actions') + .getByRole('button', { name: 'Edit address book' }).click(); + const edit = page.locator('.address-book-detail__editor'); + await edit.getByLabel('Name', { exact: true }).fill(renamed); + await edit.getByLabel(/Description/).fill('Updated BrowserStack book'); + await expect(edit.getByLabel('Set as default')).not.toBeChecked(); + await edit.getByRole('button', { name: 'Save address book' }).click(); + await expect(page.locator('.address-book-detail__display-name')).toHaveText(renamed); + await expect(page.locator('.address-book-detail__body')) + .toContainText('Updated BrowserStack book'); + await openRail(page, stormbox); + await expect(bookButton(page, renamed)).toBeVisible(); + await expect(bookButton(page, name)).toHaveCount(0); + await expect(bookButton(page, defaultName!)).toContainText('Personal'); + await expect(bookButton(page, renamed)).not.toContainText('Personal'); + await selectBook(page, stormbox, renamed); + await expect(contactRow(page, contactName)).toBeVisible(); + await contactRow(page, contactName).click(); + await expect(page.locator('.contact-detail__body')).toContainText(renamed); + }); + + test('deletes a separate address book and its sole contact', async ({ page }) => { + const suffix = uniqueSuffix(); + const name = `${BOOK_PREFIX}-${suffix}-Delete`; + const contactName = `${CONTACT_PREFIX}-${suffix}-DeletedBookMember`; + const email = `book-delete-${suffix}@example.com`; + await createBook(page, stormbox, name, 'Book to delete'); + const form = await createContact(page, stormbox, contactName, email); + await saveContact(page, form, contactName); + await selectBook(page, stormbox, name); + await expect(contactRow(page, contactName)).toBeVisible(); + const dialog = await deleteBook(page, stormbox, name); + await expect(dialog).toContainText('1 contact belongs only to this address book'); + await dialog.getByRole('button', { name: 'Delete address book' }).click(); + await expect(bookButton(page, name)).toHaveCount(0); + await selectAllContacts(page, stormbox); + await setContactFilter(stormbox, contactName); + await expect(contactRow(page, contactName)).toHaveCount(0); + await selectTrash(page, stormbox); + await expect(contactRow(page, contactName)).toHaveCount(0); + }); +}); From a29b1333f8e327bc46e37d51adce5ad0f2756f2d Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 29 Sep 2026 15:44:18 -0400 Subject: [PATCH 02/15] Move BrowserStack Contacts interactions into a page object. --- tests/browserstack/pages/contacts-page.ts | 527 +++++++++++++++++++++ tests/browserstack/tests/contacts.spec.ts | 544 +++++----------------- 2 files changed, 636 insertions(+), 435 deletions(-) create mode 100644 tests/browserstack/pages/contacts-page.ts diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts new file mode 100644 index 0000000..10734ec --- /dev/null +++ b/tests/browserstack/pages/contacts-page.ts @@ -0,0 +1,527 @@ +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 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.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/); + } + 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); + } + + /** Assert the saved UI session before changing any Contacts data. */ + 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 signs in on each fresh context; desktop reuses the prepared auth state. + 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 { + 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 selectAllContacts(): Promise { + await this.selectRailBook('All contacts'); + } + + async selectTrash(): Promise { + await this.selectRailBook('Trash'); + } + + async selectBook(name: string): Promise { + await this.selectRailBook(name); + } + + /** Require both the OIDC user and a JMAP identity in the browser account. */ + async assertContactAccount(): Promise { + const expectedEmail = PRIMARY_THUNDERMAIL_EMAIL.trim(); + expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must identify the BrowserStack JMAP account') + .toBeTruthy(); + await this.selectRailBook('Identities'); + await this.filter(expectedEmail); + await expect(this.page.locator('.contacts__row .email') + .getByText(expectedEmail, { exact: true })).toBeVisible(); + await this.filter(''); + await this.selectAllContacts(); + } + + /** 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' })); + } + + 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 defaultBookName(): Promise { + await this.openRail(); + const name = (await this.page.locator('.contacts-rail__book') + .filter({ has: this.page.locator('.contacts-rail__badge') }) + .locator('.contacts-rail__name').textContent())?.trim(); + expect(name, 'The account must have a default address book').toBeTruthy(); + return name!; + } + + async expectDefaultBook(name: string): Promise { + await this.openRail(); + await expect(this.bookButton(name)).toContainText('Personal'); + } + + async expectBookNotDefault(name: string): Promise { + await this.openRail(); + await expect(this.bookButton(name)).not.toContainText('Personal'); + } + + 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 { + 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' })); + } + + /** Permanently remove matching Trash entries left by 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(); + await this.tap(rows.first()); + 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.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(); + await expect(this.stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); + 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.assertContactAccount(); + 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(); + await this.tap(rows.first()); + await this.deleteOpenContact(); + await this.expectContactAbsent(name ?? ''); + await this.openList(); + } + 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/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 964126e..71179f2 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -1,293 +1,40 @@ import { randomUUID } from 'node:crypto'; -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { test } from '@playwright/test'; import { - ACCTS_OIDC_EMAIL, PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE, - PRIMARY_THUNDERMAIL_EMAIL, } 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'; -const CONTACT_PREFIX = 'E2E-Contact'; -const BOOK_PREFIX = 'E2E-AddressBook'; -const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; -const GIF_BASE64 = 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; - function uniqueSuffix(): string { return `${Date.now()}-${randomUUID().slice(0, 8)}`; } -function contactRow(page: Page, name: string): Locator { - return page.locator('.contacts__row').filter({ - has: page.locator('.directory-list__row-content .name').getByText(name, { exact: true }), - }); -} - -function bookButton(page: Page, name: string): Locator { - return page.locator('.contacts-rail__book').filter({ - has: page.locator('.contacts-rail__name').getByText(name, { exact: true }), - }); -} - -function detailSection(page: Page, heading: string): Locator { - return page.locator('.contact-detail__body section').filter({ - has: page.getByRole('heading', { name: heading, exact: true }), - }); -} - -function detailValueForLabel(page: Page, heading: string, label: string): Locator { - return detailSection(page, heading) - .locator('dt').filter({ hasText: new RegExp(`^${label}$`) }) - .locator('xpath=following-sibling::dd[1]'); -} - -async function setContactFilter(stormbox: StormboxPage, value: string): Promise { - await stormbox.quickFilter.fill(value); - if (value) { - await expect(stormbox.quickFilter).not.toHaveClass(/quick-filter__input--empty/); - } else { - await expect(stormbox.quickFilter).toHaveClass(/quick-filter__input--empty/); - } -} - -async function formattedDate(page: Page, isoDate: string): Promise { - return 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); -} - -async function openContacts(page: Page, stormbox: StormboxPage): Promise { - expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') - .toBeTruthy(); - await expect(stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); - await stormbox.contactsSpaceButton.click(); - await expect(page.locator('.contacts')).toBeVisible({ timeout: 30_000 }); - await expect(stormbox.quickFilter).toBeVisible(); - await setContactFilter(stormbox, ''); -} - -async function openList(page: Page): Promise { - const back = 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 page.locator('.directory-shell__list').isVisible()) return; - await back.click(); - } - await expect(page.locator('.directory-shell__list')).toBeVisible(); -} - -async function openRail(page: Page, stormbox: StormboxPage): Promise { - const sidebar = page.locator('.sidebar-slot'); - const rail = page.getByRole('navigation', { name: 'Address books' }); - if (await sidebar.getAttribute('aria-hidden') === 'false') return; - await stormbox.showAddressBookListButton.click(); - await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); - await expect(rail).toBeVisible(); -} - -async function closeRailOnPhone(page: Page): Promise { - if (await page.locator('.directory-shell').getAttribute('data-layout') !== 'phone') return; - const hide = page.getByRole('button', { name: 'Hide address book list' }); - if (await hide.isVisible()) { - await hide.click(); - await expect(page.locator('.sidebar-slot')).toHaveAttribute('aria-hidden', 'true'); - } -} - -async function selectAllContacts(page: Page, stormbox: StormboxPage): Promise { - await openList(page); - await openRail(page, stormbox); - await bookButton(page, 'All contacts').click(); - await closeRailOnPhone(page); - await expect(page.locator('.directory-list__header h2')).toHaveText('All contacts'); - await expect(page.getByRole('listbox', { name: 'All contacts' })) - .toHaveAttribute('aria-busy', 'false'); -} - -async function selectTrash(page: Page, stormbox: StormboxPage): Promise { - await openList(page); - await openRail(page, stormbox); - await bookButton(page, 'Trash').click(); - await closeRailOnPhone(page); - await expect(page.locator('.directory-list__header h2')).toHaveText('Trash'); - await expect(page.getByRole('listbox', { name: 'Trash', exact: true })) - .toHaveAttribute('aria-busy', 'false'); -} - -async function selectBook(page: Page, stormbox: StormboxPage, name: string): Promise { - await openList(page); - await openRail(page, stormbox); - await bookButton(page, name).click(); - await closeRailOnPhone(page); - await expect(page.locator('.directory-list__header h2')).toHaveText(name); - await expect(page.getByRole('listbox', { name, exact: true })) - .toHaveAttribute('aria-busy', 'false'); -} - -async function assertContactAccount(page: Page, stormbox: StormboxPage): Promise { - const expectedEmail = PRIMARY_THUNDERMAIL_EMAIL.trim(); - expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must identify the BrowserStack JMAP account') - .toBeTruthy(); - await openList(page); - await openRail(page, stormbox); - await bookButton(page, 'Identities').click(); - await closeRailOnPhone(page); - await expect(page.locator('.directory-list__header h2')).toHaveText('Identities'); - await expect(page.getByRole('listbox', { name: 'Identities' })) - .toHaveAttribute('aria-busy', 'false'); - await setContactFilter(stormbox, expectedEmail); - await expect(page.locator('.contacts__row .email').getByText(expectedEmail, { exact: true })) - .toBeVisible(); - await setContactFilter(stormbox, ''); - await selectAllContacts(page, stormbox); -} - -async function createContact( - page: Page, - stormbox: StormboxPage, - name: string, - email: string, -): Promise { - await openList(page); - await openRail(page, stormbox); - await stormbox.addContactButton.click(); - const form = page.locator('.contact-detail__editor'); - await expect(form).toBeVisible(); - await form.getByRole('textbox', { name: 'Full or display name' }).fill(name); - await form.getByRole('textbox', { name: 'Email addresses value' }).fill(email); - return form; -} - -async function chooseResourceLabel(row: Locator, kind: string, label: string): Promise { - await row.getByLabel(new RegExp(`^Choose ${kind} label`)).click(); - await row.getByRole('menuitemradio', { name: label, exact: true }).click(); - await expect(row.getByLabel(new RegExp(`^Choose ${kind} label`))) - .toHaveAttribute('aria-label', new RegExp(`current label ${label}$`)); -} - -async function saveContact(page: Page, form: Locator, name: string): Promise { - await form.getByRole('button', { name: 'Save contact' }).click(); - await expect(page.locator('.contact-detail__editor')).toBeHidden({ timeout: 30_000 }); - await expect(page.locator('.contact-detail__display-name')).toHaveText(name); -} - -async function createBook( - page: Page, - stormbox: StormboxPage, - name: string, - description: string, -): Promise { - await openList(page); - await openRail(page, stormbox); - await page.getByRole('button', { name: 'Create address book' }).click(); - const form = page.locator('.address-book-detail__editor'); - await form.getByLabel('Name', { exact: true }).fill(name); - await form.getByLabel(/Description/).fill(description); - await expect(form.getByLabel('Set as default')).not.toBeChecked(); - await form.getByRole('button', { name: 'Save address book' }).click(); - await expect(page.locator('.address-book-detail__display-name')).toHaveText(name); - await expect(page.locator('.address-book-detail__body')).toContainText(description); -} - -async function deleteBook(page: Page, stormbox: StormboxPage, name: string): Promise { - await selectBook(page, stormbox, name); - await page.locator('.directory-list__addressbook-actions') - .getByRole('button', { name: 'Delete address book' }).click(); - return page.getByRole('alertdialog'); -} - -async function purgeTestTrash(page: Page, stormbox: StormboxPage): Promise { - await selectTrash(page, stormbox); - await setContactFilter(stormbox, CONTACT_PREFIX); - const rows = page.locator('.contacts__row').filter({ - has: page.locator('.directory-list__row-content .name') - .filter({ hasText: /^E2E-Contact/ }), - }); - while (await rows.count() > 0) { - const name = await rows.first().locator('.name').textContent(); - await rows.first().click(); - await page.locator('.trash-detail').getByRole('button', { name: 'Delete Forever' }).click(); - await page.getByRole('alertdialog') - .getByRole('button', { name: 'Delete forever' }).click(); - await expect(page.locator('.contacts__row').filter({ - has: page.locator('.directory-list__row-content .name') - .getByText(name ?? '', { exact: true }), - })).toHaveCount(0); - await openList(page); - } - await setContactFilter(stormbox, ''); -} - -async function cleanUpThroughUi(page: Page, stormbox: StormboxPage): Promise { - expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') - .toBeTruthy(); - await expect(stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); - const dialog = page.getByRole('alertdialog'); - if (await dialog.isVisible()) { - await dialog.getByRole('button', { name: 'Cancel' }).click(); - } - const contactEditor = page.locator('.contact-detail__editor'); - const bookEditor = page.locator('.address-book-detail__editor'); - if (await contactEditor.isVisible()) { - await contactEditor.getByRole('button', { name: 'Cancel' }).click(); - } else if (await bookEditor.isVisible()) { - await bookEditor.getByRole('button', { name: 'Cancel' }).click(); - } - await openContacts(page, stormbox); - await assertContactAccount(page, stormbox); - await setContactFilter(stormbox, CONTACT_PREFIX); - const rows = page.locator('.contacts__row').filter({ - has: page.locator('.directory-list__row-content .name') - .filter({ hasText: /^E2E-Contact/ }), - }); - while (await rows.count() > 0) { - const name = await rows.first().locator('.name').textContent(); - await rows.first().click(); - await page.locator('.contact-detail').getByRole('button', { name: 'Delete' }).click(); - await expect(contactRow(page, name ?? '')).toHaveCount(0); - await openList(page); - } - await purgeTestTrash(page, stormbox); - - await openRail(page, stormbox); - const books = await page.locator('.contacts-rail__name').allTextContents(); - for (const name of books.filter((value) => value.startsWith(BOOK_PREFIX))) { - await selectBook(page, stormbox, name); - await expect( - bookButton(page, name).locator('.contacts-rail__count'), - `Refusing UI cleanup of nonempty address book "${name}"`, - ).toHaveText('0'); - await expect(page.locator('.contacts__row')).toHaveCount(0); - const confirmation = await deleteBook(page, stormbox, name); - await expect(confirmation).toContainText('0 contacts belong only to this address book'); - await expect(confirmation).toContainText('0 contacts have other address-book memberships'); - await confirmation.getByRole('button', { name: 'Delete address book' }).click(); - await expect(bookButton(page, name)).toHaveCount(0); - } -} - test.describe('stormbox contacts and address books', { tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE], }, () => { - let stormbox: StormboxPage; + let contacts: ContactsPage; let signedIn = false; test.beforeEach(async ({ page }, testInfo) => { signedIn = false; - stormbox = new StormboxPage(page); + const stormbox = new StormboxPage(page); + contacts = new ContactsPage(page, stormbox, testInfo.project.name); + + // The shared JMAP account is swept before each case; BrowserStack lanes must not overlap. await deleteContactsByPrefix(CONTACT_PREFIX); await deleteAddressBooksByPrefix(BOOK_PREFIX); @@ -295,202 +42,129 @@ test.describe('stormbox contacts and address books', { 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 openContacts(page, stormbox); - await assertContactAccount(page, stormbox); - await purgeTestTrash(page, stormbox); - await selectAllContacts(page, stormbox); + await contacts.open(); + await contacts.assertContactAccount(); + await contacts.purgeTestTrash(); + await contacts.selectAllContacts(); }); - test.afterEach(async ({ page }) => { - if (signedIn) await cleanUpThroughUi(page, stormbox); + 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 ({ page }, testInfo) => { + test('creates and edits a contact with its details and photo', async ({}, testInfo) => { testInfo.setTimeout(8 * 60 * 1000); const suffix = uniqueSuffix(); - const originalName = `${CONTACT_PREFIX}-${suffix}-CreateEdit`; - const renamed = `${originalName}-Renamed`; - const homeEmail = `home-${suffix}@example.com`; - const workEmail = `work-${suffix}@example.com`; - const website = `https://example.com/${suffix}/personal`; - const newWebsite = `https://example.com/${suffix}/updated`; - const originalNote = `Original contact note ${suffix}`; - const updatedNote = `Updated contact note ${suffix}`; - const birthday = await formattedDate(page, '1985-07-13'); - const weddingDate = await formattedDate(page, '2010-06-15'); - - const form = await createContact(page, stormbox, originalName, homeEmail); - await chooseResourceLabel(form.locator('.contact-resource__row').nth(0), 'email', 'Home'); - await expect(form.locator('.contact-resource__row').nth(0) - .getByRole('button', { name: 'Primary' })).toHaveAttribute('aria-pressed', 'true'); - await form.getByRole('button', { name: 'Add phone' }).click(); - const phone = form.locator('.contact-resource__row').nth(1); - await phone.getByRole('textbox', { name: 'Phone numbers value' }).fill('+15550101'); - await chooseResourceLabel(phone, 'phone', 'Work'); - await form.getByRole('button', { name: 'Add website' }).click(); - const link = form.locator('.contact-resource__row').nth(2); - await link.getByRole('textbox', { name: 'Websites value' }).fill(website); - await chooseResourceLabel(link, 'website', 'Personal'); - await form.getByRole('button', { name: 'Add date' }).click(); - await form.getByRole('textbox', { name: 'Contact date' }).fill('1985-07-13'); - await form.getByRole('button', { name: 'Add note' }).click(); - await form.getByRole('textbox', { name: 'Contact note' }).fill(originalNote); - await form.getByRole('button', { name: 'Add work' }).click(); - const work = form.locator('.contact-affiliations__card'); - await work.getByRole('textbox', { name: 'Organization' }).fill('Example Labs'); - await work.getByRole('textbox', { name: 'Department' }).fill('Research'); - await work.getByRole('textbox', { name: 'Job title' }).fill('Engineer'); - await work.getByRole('textbox', { name: 'Role' }).fill('Contributor'); - await form.locator('.contact-detail__photo-input').setInputFiles({ - name: 'contact.png', mimeType: 'image/png', buffer: Buffer.from(PNG_BASE64, 'base64'), + 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 expect(form.locator('.contact-detail__photo-editor img')) - .toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`); - await saveContact(page, form, originalName); - - const details = page.locator('.contact-detail__body'); - await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText(homeEmail); - await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText('Primary'); - await expect(detailValueForLabel(page, 'Phone numbers', 'Work')).toContainText('+15550101'); - await expect(detailValueForLabel(page, 'Websites', 'Personal')).toContainText(website); - await expect(detailValueForLabel(page, 'Dates', 'Birthday')).toHaveText(birthday); - await expect(details).toContainText(originalNote); - await expect(details).toContainText('Example Labs'); - await expect(details).toContainText('Research'); - await expect(details).toContainText('Title: Engineer'); - await expect(details).toContainText('Role: Contributor'); - await expect(page.locator('.contact-detail__avatar img')) - .toHaveAttribute('src', `data:image/png;base64,${PNG_BASE64}`); - await page.locator('.contact-detail').getByRole('button', { name: 'Edit' }).click(); - const edit = page.locator('.contact-detail__editor'); - await edit.getByRole('textbox', { name: 'Full or display name' }).fill(renamed); - await edit.getByRole('button', { name: 'Add email' }).click(); - const workEmailRow = edit.locator('.contact-resource__row').nth(1); - await workEmailRow.getByRole('textbox', { name: 'Email addresses value' }).fill(workEmail); - await chooseResourceLabel(workEmailRow, 'email', 'Work'); - await edit.locator('.contact-resource__row').nth(2) - .getByRole('textbox', { name: 'Phone numbers value' }).fill('+15550202'); - await edit.locator('.contact-resource__row').nth(3) - .getByRole('textbox', { name: 'Websites value' }).fill(newWebsite); - await edit.getByRole('button', { name: 'Add date' }).click(); - const wedding = edit.locator('.contact-dates__row').nth(1); - await wedding.getByLabel(/^Choose date kind/).click(); - await wedding.getByRole('menuitemradio', { name: 'Wedding' }).click(); - await wedding.getByRole('textbox', { name: 'Contact date' }).fill('2010-06-15'); - await edit.getByRole('textbox', { name: 'Contact note' }).fill(updatedNote); - await edit.getByRole('textbox', { name: 'Job title' }).fill('Principal Engineer'); - await edit.locator('.contact-detail__photo-input').setInputFiles({ - name: 'contact.gif', mimeType: 'image/gif', buffer: Buffer.from(GIF_BASE64, 'base64'), + await test.step('edit the same contact and replace its photo', async () => { + await contacts.editDetailedContact(edited); }); - await expect(edit.locator('.contact-detail__photo-editor img')) - .toHaveAttribute('src', `data:image/gif;base64,${GIF_BASE64}`); - await saveContact(page, edit, renamed); - await page.reload(); - await stormbox.signInIfNeeded(testInfo.project.name); - await openContacts(page, stormbox); - await selectAllContacts(page, stormbox); - await setContactFilter(stormbox, renamed); - await contactRow(page, renamed).click(); - await expect(page.locator('.contact-detail__display-name')).toHaveText(renamed); - await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText(homeEmail); - await expect(detailValueForLabel(page, 'Email addresses', 'Home')).toContainText('Primary'); - await expect(detailValueForLabel(page, 'Email addresses', 'Work')).toContainText(workEmail); - await expect(detailValueForLabel(page, 'Email addresses', 'Work')).not.toContainText('Primary'); - await expect(detailValueForLabel(page, 'Phone numbers', 'Work')).toContainText('+15550202'); - await expect(detailValueForLabel(page, 'Websites', 'Personal')).toContainText(newWebsite); - await expect(detailValueForLabel(page, 'Dates', 'Birthday')).toHaveText(birthday); - await expect(detailValueForLabel(page, 'Dates', 'Wedding')).toHaveText(weddingDate); - await expect(details).toContainText(updatedNote); - await expect(details).toContainText('Title: Principal Engineer'); - await expect(details).toContainText('Example Labs'); - await expect(details).toContainText('Research'); - await expect(details).toContainText('Role: Contributor'); - await expect(details).not.toContainText('+15550101'); - await expect(details).not.toContainText(website); - await expect(details).not.toContainText(originalNote); - await expect(page.locator('.contact-detail__avatar img')) - .toHaveAttribute('src', `data:image/gif;base64,${GIF_BASE64}`); + await test.step('reload and verify changed and retained details', async () => { + await contacts.reloadAndOpen(); + await contacts.selectAllContacts(); + 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 separate contact into Trash', async ({ page }) => { + test('deletes a separate contact into Trash', async () => { const suffix = uniqueSuffix(); const name = `${CONTACT_PREFIX}-${suffix}-Delete`; const email = `delete-${suffix}@example.com`; - const form = await createContact(page, stormbox, name, email); - await saveContact(page, form, name); - await selectAllContacts(page, stormbox); - await setContactFilter(stormbox, name); - await expect(contactRow(page, name)).toBeVisible(); - await contactRow(page, name).click(); - await page.locator('.contact-detail').getByRole('button', { name: 'Delete' }).click(); - await expect(contactRow(page, name)).toHaveCount(0); - await selectTrash(page, stormbox); - await expect(contactRow(page, name)).toBeVisible(); - await contactRow(page, name).click(); - await expect(page.locator('.trash-detail')).toContainText(name); + + await contacts.createBasicContact(name, email); + await contacts.selectAllContacts(); + 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 ({ page }) => { + 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`; - const email = `book-${suffix}@example.com`; - await openRail(page, stormbox); - const defaultName = await page.locator('.contacts-rail__book') - .filter({ has: page.locator('.contacts-rail__badge') }) - .locator('.contacts-rail__name').textContent(); - expect(defaultName).toBeTruthy(); - - await createBook(page, stormbox, name, 'Initial BrowserStack book'); - await expect(bookButton(page, name)).not.toContainText('Personal'); - const form = await createContact(page, stormbox, contactName, email); - await saveContact(page, form, contactName); - await selectBook(page, stormbox, name); - await expect(contactRow(page, contactName)).toBeVisible(); - await page.locator('.directory-list__addressbook-actions') - .getByRole('button', { name: 'Edit address book' }).click(); - const edit = page.locator('.address-book-detail__editor'); - await edit.getByLabel('Name', { exact: true }).fill(renamed); - await edit.getByLabel(/Description/).fill('Updated BrowserStack book'); - await expect(edit.getByLabel('Set as default')).not.toBeChecked(); - await edit.getByRole('button', { name: 'Save address book' }).click(); - await expect(page.locator('.address-book-detail__display-name')).toHaveText(renamed); - await expect(page.locator('.address-book-detail__body')) - .toContainText('Updated BrowserStack book'); - await openRail(page, stormbox); - await expect(bookButton(page, renamed)).toBeVisible(); - await expect(bookButton(page, name)).toHaveCount(0); - await expect(bookButton(page, defaultName!)).toContainText('Personal'); - await expect(bookButton(page, renamed)).not.toContainText('Personal'); - await selectBook(page, stormbox, renamed); - await expect(contactRow(page, contactName)).toBeVisible(); - await contactRow(page, contactName).click(); - await expect(page.locator('.contact-detail__body')).toContainText(renamed); + const defaultName = await contacts.defaultBookName(); + + 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.expectDefaultBook(defaultName); + await contacts.expectBookNotDefault(renamed); + await contacts.expectContactInBook(contactName, renamed); }); - test('deletes a separate address book and its sole contact', async ({ page }) => { + test('deletes a separate address book and its sole contact', async () => { const suffix = uniqueSuffix(); const name = `${BOOK_PREFIX}-${suffix}-Delete`; const contactName = `${CONTACT_PREFIX}-${suffix}-DeletedBookMember`; - const email = `book-delete-${suffix}@example.com`; - await createBook(page, stormbox, name, 'Book to delete'); - const form = await createContact(page, stormbox, contactName, email); - await saveContact(page, form, contactName); - await selectBook(page, stormbox, name); - await expect(contactRow(page, contactName)).toBeVisible(); - const dialog = await deleteBook(page, stormbox, name); - await expect(dialog).toContainText('1 contact belongs only to this address book'); - await dialog.getByRole('button', { name: 'Delete address book' }).click(); - await expect(bookButton(page, name)).toHaveCount(0); - await selectAllContacts(page, stormbox); - await setContactFilter(stormbox, contactName); - await expect(contactRow(page, contactName)).toHaveCount(0); - await selectTrash(page, stormbox); - await expect(contactRow(page, contactName)).toHaveCount(0); + + 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.selectAllContacts(); + await contacts.filter(contactName); + await contacts.expectContactAbsent(contactName); + await contacts.selectTrash(); + await contacts.expectContactAbsent(contactName); }); }); From 14aa9ef07f8337c4449883afa4e52f2a262ddc84 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 29 Sep 2026 15:48:56 -0400 Subject: [PATCH 03/15] Avoid waiting for an unmounted Android Contacts list. --- tests/browserstack/pages/contacts-page.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index 10734ec..e08f55d 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -126,8 +126,11 @@ export class ContactsPage { } else { await expect(this.stormbox.quickFilter).toHaveClass(/quick-filter__input--empty/); } - await expect(this.page.locator('.directory-list__viewport')) - .toHaveAttribute('aria-busy', 'false'); + // 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 { @@ -154,7 +157,7 @@ export class ContactsPage { async reloadAndOpen(): Promise { await this.page.reload(); - // Android signs in on each fresh context; desktop reuses the prepared auth state. + // Android has no prepared storage state, so check sign-in after reloading. await this.stormbox.signInIfNeeded(this.projectName); await this.open(); } From e009bfa876284d08798e9da2d8da4b2c7d242291 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 29 Sep 2026 15:51:54 -0400 Subject: [PATCH 04/15] Close the Android Contacts drawer before list navigation. --- tests/browserstack/pages/contacts-page.ts | 55 ++++++++++++++++++----- tests/browserstack/tests/contacts.spec.ts | 24 ++++++---- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index e08f55d..da96648 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -144,7 +144,7 @@ export class ContactsPage { }, isoDate); } - /** Assert the saved UI session before changing any Contacts data. */ + /** 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(); @@ -164,6 +164,8 @@ export class ContactsPage { /** 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"], ' @@ -207,7 +209,7 @@ export class ContactsPage { .toHaveAttribute('aria-busy', 'false'); } - async selectAllContacts(): Promise { + async displayAllContacts(): Promise { await this.selectRailBook('All contacts'); } @@ -219,17 +221,42 @@ export class ContactsPage { await this.selectRailBook(name); } - /** Require both the OIDC user and a JMAP identity in the browser account. */ - async assertContactAccount(): Promise { + /** Check that the Primary identity has the configured address. */ + async assertExpectedPrimaryMailIdentityVisible(): Promise { const expectedEmail = PRIMARY_THUNDERMAIL_EMAIL.trim(); - expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must identify the BrowserStack JMAP account') + expect(expectedEmail, 'PRIMARY_THUNDERMAIL_EMAIL must contain the expected primary identity') .toBeTruthy(); await this.selectRailBook('Identities'); await this.filter(expectedEmail); - await expect(this.page.locator('.contacts__row .email') - .getByText(expectedEmail, { exact: true })).toBeVisible(); + 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.selectAllContacts(); + await this.displayAllContacts(); } /** New contacts inherit the currently selected address book. */ @@ -461,7 +488,7 @@ export class ContactsPage { await this.tap(this.confirmationDialog.getByRole('button', { name: 'Delete address book' })); } - /** Permanently remove matching Trash entries left by interrupted runs. */ + /** 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); @@ -486,7 +513,7 @@ export class ContactsPage { async cleanUpThroughUi(): Promise { expect(ACCTS_OIDC_EMAIL, 'ACCTS_OIDC_EMAIL must identify the BrowserStack UI account') .toBeTruthy(); - await expect(this.stormbox.accountMenuIdentity).toHaveText(ACCTS_OIDC_EMAIL); + if (await this.confirmationDialog.isVisible()) { await this.tap(this.confirmationDialog.getByRole('button', { name: 'Cancel' })); } @@ -495,13 +522,16 @@ export class ContactsPage { } else if (await this.bookForm.isVisible()) { await this.tap(this.bookForm.getByRole('button', { name: 'Cancel' })); } + await this.open(); - await this.assertContactAccount(); + 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(); await this.tap(rows.first()); @@ -510,9 +540,10 @@ export class ContactsPage { await this.openList(); } 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( diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 71179f2..19f8f05 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -34,6 +34,8 @@ test.describe('stormbox contacts and address books', { const stormbox = new StormboxPage(page); contacts = new ContactsPage(page, stormbox, testInfo.project.name); + // Connects to the Webmail's corresponding Thundermail/mailstrom account to clean-up any + // contacts and address books that happen to have been leftover from previous test runs; // The shared JMAP account is swept before each case; BrowserStack lanes must not overlap. await deleteContactsByPrefix(CONTACT_PREFIX); await deleteAddressBooksByPrefix(BOOK_PREFIX); @@ -41,14 +43,20 @@ test.describe('stormbox contacts and address books', { 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.assertContactAccount(); + await contacts.assertExpectedPrimaryMailIdentityVisible(); + + // JMAP was used above to clean out any previously leftover contacts; deleted contacts have + // separate Trash records that the JMAP card sweep above does not remove; so now remove the + // cleaned-up/deleted contacts from the trash using the Webmail UI itself await contacts.purgeTestTrash(); - await contacts.selectAllContacts(); + await contacts.displayAllContacts(); }); test.afterEach(async () => { @@ -92,7 +100,7 @@ test.describe('stormbox contacts and address books', { await test.step('reload and verify changed and retained details', async () => { await contacts.reloadAndOpen(); - await contacts.selectAllContacts(); + await contacts.displayAllContacts(); await contacts.filter(edited.name); await contacts.openContact(edited.name); await contacts.expectDetailedContact({ @@ -104,13 +112,13 @@ test.describe('stormbox contacts and address books', { }); }); - test('deletes a separate contact into Trash', async () => { + test.skip('deletes a separate contact into Trash', async () => { const suffix = uniqueSuffix(); const name = `${CONTACT_PREFIX}-${suffix}-Delete`; const email = `delete-${suffix}@example.com`; await contacts.createBasicContact(name, email); - await contacts.selectAllContacts(); + await contacts.displayAllContacts(); await contacts.filter(name); await contacts.expectContactRow(name); await contacts.openContact(name); @@ -123,7 +131,7 @@ test.describe('stormbox contacts and address books', { await contacts.openTrashedContact(name); }); - test('creates and edits an address book containing a contact', async () => { + test.skip('creates and edits an address book containing a contact', async () => { const suffix = uniqueSuffix(); const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; const renamed = `${name}-Renamed`; @@ -144,7 +152,7 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactInBook(contactName, renamed); }); - test('deletes a separate address book and its sole contact', async () => { + test.skip('deletes a separate address book and its sole contact', async () => { const suffix = uniqueSuffix(); const name = `${BOOK_PREFIX}-${suffix}-Delete`; const contactName = `${CONTACT_PREFIX}-${suffix}-DeletedBookMember`; @@ -161,7 +169,7 @@ test.describe('stormbox contacts and address books', { await contacts.expectBookAbsent(name); // Deleting an exclusive book card is permanent; it must not enter Trash. - await contacts.selectAllContacts(); + await contacts.displayAllContacts(); await contacts.filter(contactName); await contacts.expectContactAbsent(contactName); await contacts.selectTrash(); From 41b9177c74410e549c8d95053493d89ba2b745e9 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 13:30:22 -0400 Subject: [PATCH 05/15] Wait for Contacts deletion to finish before opening Trash. --- tests/browserstack/pages/contacts-page.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index da96648..a01616c 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -54,6 +54,7 @@ export class ContactsPage { readonly rail: Locator; readonly list: Locator; readonly listHeading: Locator; + readonly listNotice: Locator; readonly contactForm: Locator; readonly contactDetails: Locator; readonly bookForm: Locator; @@ -70,6 +71,7 @@ export class ContactsPage { 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'); @@ -397,6 +399,8 @@ export class ContactsPage { 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 { @@ -497,13 +501,15 @@ export class ContactsPage { .filter({ hasText: /^E2E-Contact/ }), }); while (await rows.count() > 0) { - const name = await rows.first().locator('.name').textContent(); - await this.tap(rows.first()); + 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.contactRow(name ?? '')).toHaveCount(0); + 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(''); @@ -533,10 +539,11 @@ export class ContactsPage { }); while (await rows.count() > 0) { - const name = await rows.first().locator('.name').textContent(); - await this.tap(rows.first()); + const name = (await rows.first().locator('.name').textContent())?.trim(); + expect(name, 'Contact cleanup requires a named test contact').toBeTruthy(); + await this.openContact(name!); await this.deleteOpenContact(); - await this.expectContactAbsent(name ?? ''); + await this.expectContactAbsent(name!); await this.openList(); } await this.purgeTestTrash(); From 972c4a8ec99799f3b34c48c06949f87c2292e73f Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 13:35:57 -0400 Subject: [PATCH 06/15] Keep contact cleanup on the list after Android deletion. --- tests/browserstack/pages/contacts-page.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index a01616c..899537a 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -541,10 +541,15 @@ export class ContactsPage { 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(); From 6904f5c9dc8287726f02f0e82ff1d7dd039317e2 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 14:18:23 -0400 Subject: [PATCH 07/15] Handle accounts without a default address book in BrowserStack tests. --- tests/browserstack/README.md | 2 +- tests/browserstack/helpers/jmap-client.ts | 10 ++++++++++ tests/browserstack/pages/contacts-page.ts | 23 +++++++++++------------ tests/browserstack/tests/contacts.spec.ts | 9 ++++++--- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index 1a27e96..bfafad6 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 and Contacts suites use direct JMAP access from the Node test runner solely to remove test data 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 to remove test data left by earlier interrupted runs. The Contacts address-book case also reads the server's default book before and after editing. 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 8f34566..86d5d17 100644 --- a/tests/browserstack/helpers/jmap-client.ts +++ b/tests/browserstack/helpers/jmap-client.ts @@ -364,6 +364,16 @@ async function listAddressBooks(client: JmapContactsClient): Promise { + const books = await listAddressBooks(await connectContactsJmap()); + const defaults = books.filter((book) => book.isDefault); + if (defaults.length > 1) { + throw new Error('The JMAP account has more than one default address book'); + } + return defaults[0] ? { id: defaults[0].id, name: defaults[0].name } : null; +} + /** Refuse books containing other cards; never destroy their contents implicitly. */ export async function deleteAddressBooksByPrefix(prefix: string): Promise { if (prefix !== 'E2E-AddressBook') { diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index 899537a..8316385 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -412,23 +412,22 @@ export class ContactsPage { await this.expectTrashedContact(name); } - async defaultBookName(): Promise { + async expectDefaultBook(name: string | null): Promise { await this.openRail(); - const name = (await this.page.locator('.contacts-rail__book') - .filter({ has: this.page.locator('.contacts-rail__badge') }) - .locator('.contacts-rail__name').textContent())?.trim(); - expect(name, 'The account must have a default address book').toBeTruthy(); - return name!; - } - - async expectDefaultBook(name: string): Promise { - await this.openRail(); - await expect(this.bookButton(name)).toContainText('Personal'); + const defaultBooks = this.page.locator('.contacts-rail__book').filter({ + has: this.page.locator('.contacts-rail__badge') + .getByText('Personal', { exact: true }), + }); + await expect(defaultBooks).toHaveCount(name === null ? 0 : 1); + if (name !== null) { + await expect(defaultBooks.locator('.contacts-rail__name')).toHaveText(name); + } } async expectBookNotDefault(name: string): Promise { await this.openRail(); - await expect(this.bookButton(name)).not.toContainText('Personal'); + await expect(this.bookButton(name)).toBeVisible(); + await expect(this.bookButton(name).locator('.contacts-rail__badge')).toHaveCount(0); } async expectBookAbsent(name: string): Promise { diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 19f8f05..53ecd8b 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { PLAYWRIGHT_TAG_DESKTOP, @@ -9,6 +9,7 @@ import { import { deleteAddressBooksByPrefix, deleteContactsByPrefix, + getDefaultAddressBook, } from '../helpers/jmap-client'; import { BOOK_PREFIX, @@ -136,7 +137,8 @@ test.describe('stormbox contacts and address books', { const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; const renamed = `${name}-Renamed`; const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; - const defaultName = await contacts.defaultBookName(); + const defaultBook = await getDefaultAddressBook(); + await contacts.expectDefaultBook(defaultBook?.name ?? null); await contacts.createBook(name, 'Initial BrowserStack book'); await contacts.expectBookNotDefault(name); @@ -147,7 +149,8 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactRow(contactName); await contacts.editBook(name, renamed, 'Updated BrowserStack book'); - await contacts.expectDefaultBook(defaultName); + expect(await getDefaultAddressBook()).toEqual(defaultBook); + await contacts.expectDefaultBook(defaultBook?.name ?? null); await contacts.expectBookNotDefault(renamed); await contacts.expectContactInBook(contactName, renamed); }); From 34237fb0b0d071f5fecfbd6d8b9bd0079e18c65c Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 14:21:47 -0400 Subject: [PATCH 08/15] Clarify BrowserStack UI assertions and JMAP reads. --- tests/browserstack/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index bfafad6..92c2b46 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 and Contacts suites use direct JMAP access from the Node test runner to remove test data left by earlier interrupted runs. The Contacts address-book case also reads the server's default book before and after editing. +This package contains Playwright tests for deployed Stormbox stage and production. Test actions and UI 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 to remove test data left by earlier interrupted runs. The Contacts address-book case also reads the server's default book before and after editing. 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. From 2b06ec7a186b7136b51304696dfd8395e9d02736 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 14:33:17 -0400 Subject: [PATCH 09/15] Track the effective contact creation book in BrowserStack tests. --- tests/browserstack/README.md | 2 +- tests/browserstack/helpers/jmap-client.ts | 13 ++++++++----- tests/browserstack/pages/contacts-page.ts | 11 +++++++---- tests/browserstack/tests/contacts.spec.ts | 10 +++++----- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index 92c2b46..3637b5c 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 UI 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 to remove test data left by earlier interrupted runs. The Contacts address-book case also reads the server's default book before and after editing. +This package contains Playwright tests for deployed Stormbox stage and production. Test actions and UI 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 to remove test data left by earlier interrupted runs. The Contacts address-book case also checks the server's chosen book for new contacts before and after editing. 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 86d5d17..668ee66 100644 --- a/tests/browserstack/helpers/jmap-client.ts +++ b/tests/browserstack/helpers/jmap-client.ts @@ -8,8 +8,8 @@ * normal TB Accounts password remains reserved for signing into Stormbox in * the browser. * - * Keep this helper limited to test-data maintenance. BrowserStack test - * actions and assertions should continue to exercise the Stormbox UI. + * Keep this helper limited to test-data maintenance and read-only account + * metadata checks. BrowserStack UI flows run through Stormbox. */ import { @@ -364,14 +364,17 @@ async function listAddressBooks(client: JmapContactsClient): Promise { +/** Match the All Contacts filing target when RFC 9610 supplies no default. */ +export async function getContactCreationBook(): Promise { const books = await listAddressBooks(await connectContactsJmap()); const defaults = books.filter((book) => book.isDefault); if (defaults.length > 1) { throw new Error('The JMAP account has more than one default address book'); } - return defaults[0] ? { id: defaults[0].id, name: defaults[0].name } : null; + return defaults[0] + ?? books.find((book) => book.name.trim().toLocaleLowerCase() !== 'trusted senders') + ?? books[0] + ?? null; } /** Refuse books containing other cards; never destroy their contents implicitly. */ diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index 8316385..6c75282 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -412,15 +412,18 @@ export class ContactsPage { await this.expectTrashedContact(name); } - async expectDefaultBook(name: string | null): Promise { + async expectContactCreationBook(book: { name: string; isDefault: boolean } | null): Promise { await this.openRail(); const defaultBooks = this.page.locator('.contacts-rail__book').filter({ has: this.page.locator('.contacts-rail__badge') .getByText('Personal', { exact: true }), }); - await expect(defaultBooks).toHaveCount(name === null ? 0 : 1); - if (name !== null) { - await expect(defaultBooks.locator('.contacts-rail__name')).toHaveText(name); + await expect(defaultBooks).toHaveCount(book?.isDefault ? 1 : 0); + if (book) { + await expect(this.bookButton(book.name)).toBeVisible(); + if (book.isDefault) { + await expect(defaultBooks.locator('.contacts-rail__name')).toHaveText(book.name); + } } } diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 53ecd8b..43c1060 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -9,7 +9,7 @@ import { import { deleteAddressBooksByPrefix, deleteContactsByPrefix, - getDefaultAddressBook, + getContactCreationBook, } from '../helpers/jmap-client'; import { BOOK_PREFIX, @@ -137,8 +137,8 @@ test.describe('stormbox contacts and address books', { const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; const renamed = `${name}-Renamed`; const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; - const defaultBook = await getDefaultAddressBook(); - await contacts.expectDefaultBook(defaultBook?.name ?? null); + const creationBook = await getContactCreationBook(); + await contacts.expectContactCreationBook(creationBook); await contacts.createBook(name, 'Initial BrowserStack book'); await contacts.expectBookNotDefault(name); @@ -149,8 +149,8 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactRow(contactName); await contacts.editBook(name, renamed, 'Updated BrowserStack book'); - expect(await getDefaultAddressBook()).toEqual(defaultBook); - await contacts.expectDefaultBook(defaultBook?.name ?? null); + expect(await getContactCreationBook()).toEqual(creationBook); + await contacts.expectContactCreationBook(creationBook); await contacts.expectBookNotDefault(renamed); await contacts.expectContactInBook(contactName, renamed); }); From b790d6c7c66e41786c4a4dcd4fdecdf391af3efd Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 15:04:46 -0400 Subject: [PATCH 10/15] Clarify BrowserStack Contacts test setup and filing assertions. --- tests/browserstack/helpers/jmap-client.ts | 2 +- tests/browserstack/pages/contacts-page.ts | 1 + tests/browserstack/tests/contacts.spec.ts | 13 +++++++------ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/browserstack/helpers/jmap-client.ts b/tests/browserstack/helpers/jmap-client.ts index 668ee66..06412b2 100644 --- a/tests/browserstack/helpers/jmap-client.ts +++ b/tests/browserstack/helpers/jmap-client.ts @@ -364,7 +364,7 @@ async function listAddressBooks(client: JmapContactsClient): Promise { const books = await listAddressBooks(await connectContactsJmap()); const defaults = books.filter((book) => book.isDefault); diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index 6c75282..3eb265a 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -412,6 +412,7 @@ export class ContactsPage { await this.expectTrashedContact(name); } + /** A fallback filing book has no Personal badge; only a designated default does. */ async expectContactCreationBook(book: { name: string; isDefault: boolean } | null): Promise { await this.openRail(); const defaultBooks = this.page.locator('.contacts-rail__book').filter({ diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 43c1060..64f686d 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -35,9 +35,8 @@ test.describe('stormbox contacts and address books', { const stormbox = new StormboxPage(page); contacts = new ContactsPage(page, stormbox, testInfo.project.name); - // Connects to the Webmail's corresponding Thundermail/mailstrom account to clean-up any - // contacts and address books that happen to have been leftover from previous test runs; - // The shared JMAP account is swept before each case; BrowserStack lanes must not overlap. + // 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); @@ -53,9 +52,7 @@ test.describe('stormbox contacts and address books', { await contacts.open(); await contacts.assertExpectedPrimaryMailIdentityVisible(); - // JMAP was used above to clean out any previously leftover contacts; deleted contacts have - // separate Trash records that the JMAP card sweep above does not remove; so now remove the - // cleaned-up/deleted contacts from the trash using the Webmail UI itself + // The JMAP card sweep leaves recoverable Contacts Trash entries behind. await contacts.purgeTestTrash(); await contacts.displayAllContacts(); }); @@ -137,6 +134,8 @@ test.describe('stormbox contacts and address books', { const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; const renamed = `${name}-Renamed`; const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; + // All Contacts files new cards in the server default, or the first regular book + // if none is designated. Editing a separate book must preserve that target. const creationBook = await getContactCreationBook(); await contacts.expectContactCreationBook(creationBook); @@ -149,6 +148,8 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactRow(contactName); await contacts.editBook(name, renamed, 'Updated BrowserStack book'); + // Compare the JMAP id as well as the name and default flag: a fallback book + // is still the filing target even though it has no Personal badge. expect(await getContactCreationBook()).toEqual(creationBook); await contacts.expectContactCreationBook(creationBook); await contacts.expectBookNotDefault(renamed); From 6c3570a100e31ad2813aee4a9009655cf70f6931 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 15:10:00 -0400 Subject: [PATCH 11/15] Clarify the Contacts filing target assertion comment. --- tests/browserstack/tests/contacts.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index 64f686d..aac67f5 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -134,8 +134,8 @@ test.describe('stormbox contacts and address books', { const name = `${BOOK_PREFIX}-${suffix}-CreateEdit`; const renamed = `${name}-Renamed`; const contactName = `${CONTACT_PREFIX}-${suffix}-BookMember`; - // All Contacts files new cards in the server default, or the first regular book - // if none is designated. Editing a separate book must preserve that target. + // Capture All Contacts' existing-book choice before creating a separate book. + // With no server-designated default, that choice may have no Personal badge. const creationBook = await getContactCreationBook(); await contacts.expectContactCreationBook(creationBook); @@ -148,8 +148,8 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactRow(contactName); await contacts.editBook(name, renamed, 'Updated BrowserStack book'); - // Compare the JMAP id as well as the name and default flag: a fallback book - // is still the filing target even though it has no Personal badge. + // The JMAP comparison pins the chosen book's id, name, and default flag; + // a fallback may have no Personal badge to identify it in the UI. expect(await getContactCreationBook()).toEqual(creationBook); await contacts.expectContactCreationBook(creationBook); await contacts.expectBookNotDefault(renamed); From 35c88b15ec253eb44482250ea7a96c22ab3f06ed Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 15:15:26 -0400 Subject: [PATCH 12/15] Simplify the BrowserStack address book edit test. --- tests/browserstack/README.md | 15 +-------------- tests/browserstack/helpers/jmap-client.ts | 17 ++--------------- tests/browserstack/pages/contacts-page.ts | 16 ---------------- tests/browserstack/tests/contacts.spec.ts | 18 ++++-------------- 4 files changed, 7 insertions(+), 59 deletions(-) diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index 3637b5c..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 UI 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 to remove test data left by earlier interrupted runs. The Contacts address-book case also checks the server's chosen book for new contacts before and after editing. +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. @@ -85,16 +85,3 @@ npm run e2e:browserstack:mobile:android:chrome ``` Desktop runs authenticate once in `tests/auth.desktop.ts` and save `test-results/.auth/user.json`. Android mobile runs sign in through the UI for each test because BrowserStack mobile contexts cannot use the saved desktop auth state. - -## Contacts and address books - -The full desktop and mobile suites include four independent Contacts cases: - -| Case | UI flow | -| --- | --- | -| Create/edit contact | Create an `E2E-Contact` with a primary Home email, Work phone, Personal website, birthday, note, Work affiliation, and PNG photo. Edit its name, add a Work email and wedding date, change the phone, website, note, and title, and replace the photo with a GIF. Reload and check changed and preserved details. | -| Delete contact | Create a separate named contact with an email, remove it from All Contacts, and check it appears in Trash. Cleanup uses Delete Forever. | -| Create/edit address book | Create an `E2E-AddressBook` with a description without making it the default, add a contact inside it, rename the book and edit its description, and check the contact remains in it while the default book stays the same. | -| Delete address book | Create another book and sole contact, check the delete dialog reports one exclusive contact, then delete the book. Check the contact is absent from All Contacts and Trash. | - -Each case uses a unique suffix. Before each test, direct JMAP cleanup deletes matching cards, then empty matching books, and the UI purges matching Trash entries after sign-in. After each test, the UI removes remaining test data. Runs using the same test account must not overlap because they sweep the shared `E2E-Contact` and `E2E-AddressBook` prefixes. The four production workflows share a concurrency group and start at 05:00, 05:45, 06:30, and 07:15 UTC. Run manual BrowserStack sessions one at a time, outside those nightly jobs. diff --git a/tests/browserstack/helpers/jmap-client.ts b/tests/browserstack/helpers/jmap-client.ts index 06412b2..8f34566 100644 --- a/tests/browserstack/helpers/jmap-client.ts +++ b/tests/browserstack/helpers/jmap-client.ts @@ -8,8 +8,8 @@ * normal TB Accounts password remains reserved for signing into Stormbox in * the browser. * - * Keep this helper limited to test-data maintenance and read-only account - * metadata checks. BrowserStack UI flows run through Stormbox. + * Keep this helper limited to test-data maintenance. BrowserStack test + * actions and assertions should continue to exercise the Stormbox UI. */ import { @@ -364,19 +364,6 @@ async function listAddressBooks(client: JmapContactsClient): Promise { - const books = await listAddressBooks(await connectContactsJmap()); - const defaults = books.filter((book) => book.isDefault); - if (defaults.length > 1) { - throw new Error('The JMAP account has more than one default address book'); - } - return defaults[0] - ?? books.find((book) => book.name.trim().toLocaleLowerCase() !== 'trusted senders') - ?? books[0] - ?? null; -} - /** Refuse books containing other cards; never destroy their contents implicitly. */ export async function deleteAddressBooksByPrefix(prefix: string): Promise { if (prefix !== 'E2E-AddressBook') { diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index 3eb265a..d96276b 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -412,22 +412,6 @@ export class ContactsPage { await this.expectTrashedContact(name); } - /** A fallback filing book has no Personal badge; only a designated default does. */ - async expectContactCreationBook(book: { name: string; isDefault: boolean } | null): Promise { - await this.openRail(); - const defaultBooks = this.page.locator('.contacts-rail__book').filter({ - has: this.page.locator('.contacts-rail__badge') - .getByText('Personal', { exact: true }), - }); - await expect(defaultBooks).toHaveCount(book?.isDefault ? 1 : 0); - if (book) { - await expect(this.bookButton(book.name)).toBeVisible(); - if (book.isDefault) { - await expect(defaultBooks.locator('.contacts-rail__name')).toHaveText(book.name); - } - } - } - async expectBookNotDefault(name: string): Promise { await this.openRail(); await expect(this.bookButton(name)).toBeVisible(); diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index aac67f5..cccf314 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; import { PLAYWRIGHT_TAG_DESKTOP, @@ -9,7 +9,6 @@ import { import { deleteAddressBooksByPrefix, deleteContactsByPrefix, - getContactCreationBook, } from '../helpers/jmap-client'; import { BOOK_PREFIX, @@ -110,7 +109,7 @@ test.describe('stormbox contacts and address books', { }); }); - test.skip('deletes a separate contact into Trash', async () => { + test('deletes a separate contact into Trash', async () => { const suffix = uniqueSuffix(); const name = `${CONTACT_PREFIX}-${suffix}-Delete`; const email = `delete-${suffix}@example.com`; @@ -129,16 +128,11 @@ test.describe('stormbox contacts and address books', { await contacts.openTrashedContact(name); }); - test.skip('creates and edits an address book containing a contact', async () => { + 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`; - // Capture All Contacts' existing-book choice before creating a separate book. - // With no server-designated default, that choice may have no Personal badge. - const creationBook = await getContactCreationBook(); - await contacts.expectContactCreationBook(creationBook); - await contacts.createBook(name, 'Initial BrowserStack book'); await contacts.expectBookNotDefault(name); // Creating in the selected book gives the card a single book membership. @@ -148,15 +142,11 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactRow(contactName); await contacts.editBook(name, renamed, 'Updated BrowserStack book'); - // The JMAP comparison pins the chosen book's id, name, and default flag; - // a fallback may have no Personal badge to identify it in the UI. - expect(await getContactCreationBook()).toEqual(creationBook); - await contacts.expectContactCreationBook(creationBook); await contacts.expectBookNotDefault(renamed); await contacts.expectContactInBook(contactName, renamed); }); - test.skip('deletes a separate address book and its sole contact', async () => { + test('deletes a separate address book and its sole contact', async () => { const suffix = uniqueSuffix(); const name = `${BOOK_PREFIX}-${suffix}-Delete`; const contactName = `${CONTACT_PREFIX}-${suffix}-DeletedBookMember`; From 34225bb7c95775cd2d4af5ca20e1869920a04cb2 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 16:40:19 -0400 Subject: [PATCH 13/15] Reopen renamed address book before selecting its contact. --- tests/browserstack/pages/contacts-page.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/browserstack/pages/contacts-page.ts b/tests/browserstack/pages/contacts-page.ts index d96276b..873dd2e 100644 --- a/tests/browserstack/pages/contacts-page.ts +++ b/tests/browserstack/pages/contacts-page.ts @@ -452,6 +452,9 @@ export class ContactsPage { } 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); From 567eeb2291424f9b5230862c753ecf2314d46589 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 16:48:18 -0400 Subject: [PATCH 14/15] Add temporary browserstack file to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 96d8ca9a1ee392ee0b1676271051b998f44d9ab0 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Fri, 2 Oct 2026 17:10:08 -0400 Subject: [PATCH 15/15] Change test names --- tests/browserstack/tests/contacts.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browserstack/tests/contacts.spec.ts b/tests/browserstack/tests/contacts.spec.ts index cccf314..3e6910b 100644 --- a/tests/browserstack/tests/contacts.spec.ts +++ b/tests/browserstack/tests/contacts.spec.ts @@ -109,7 +109,7 @@ test.describe('stormbox contacts and address books', { }); }); - test('deletes a separate contact into Trash', async () => { + test('deletes a contact', async () => { const suffix = uniqueSuffix(); const name = `${CONTACT_PREFIX}-${suffix}-Delete`; const email = `delete-${suffix}@example.com`; @@ -146,7 +146,7 @@ test.describe('stormbox contacts and address books', { await contacts.expectContactInBook(contactName, renamed); }); - test('deletes a separate address book and its sole contact', async () => { + 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`;