From 59eeb3bd13bb1c65844661be92c3943221e8a861 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 11 Aug 2026 15:00:42 -0400 Subject: [PATCH 01/11] Update smoke test so it will work regardless on if inbox is empty or not --- tests/browserstack/pages/stormbox-page.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 158e25d..3b39e13 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -47,6 +47,7 @@ export class StormboxPage { readonly showWelcomeButton: Locator; readonly selectAllMessagesCheckbox: Locator; readonly unreadFilterButton: Locator; + readonly inboxEmptyText: Locator; readonly messageCount: Locator; readonly messageRefreshButton: Locator; readonly loadingInboxMessage: Locator; @@ -60,6 +61,7 @@ export class StormboxPage { readonly cancelContactButton: Locator; readonly welcomeDialog: Locator; readonly getStartedButton: Locator; + readonly manageFoldersButton: Locator; constructor(page: Page) { this.page = page; @@ -97,6 +99,7 @@ export class StormboxPage { this.showWelcomeButton = this.settingsDialog.getByRole('button', { name: /^show welcome$/i }); this.selectAllMessagesCheckbox = page.locator('.msg-list__select-all input[type="checkbox"]'); this.unreadFilterButton = page.getByRole('button', { name: /^unread$/i }); + this.inboxEmptyText = page.getByText('Inbox is empty'); this.messageCount = page.locator('.msg-list__count'); this.messageRefreshButton = page.locator('.msg-list__refresh'); this.loadingInboxMessage = page.locator('.msg-list__loader, .msg-list__placeholder') @@ -112,6 +115,7 @@ export class StormboxPage { this.cancelContactButton = page.locator('.contacts__form').getByRole('button', { name: /^cancel$/i }); this.welcomeDialog = page.getByRole('dialog', { name: /welcome to thundermail/i }); this.getStartedButton = page.getByRole('button', { name: /^get started$/i }); + this.manageFoldersButton = page.getByRole('button', { name: 'Manage Folders' }); } /** A bar action on desktop, or the same action as a compact-menu item. */ @@ -218,8 +222,12 @@ export class StormboxPage { await this.assertAccountMenuItemsVisible(); await expect(this.selectAllMessagesCheckbox).toBeVisible(); await expect(this.unreadFilterButton).toBeVisible(); - await expect(this.messageCount).toHaveText(/\d+\s+messages?/i, { timeout: TIMEOUT_60_SECONDS }); await expect(this.messageRefreshButton).toBeVisible(); + + // the inbox might have messages and might not; if there are messages check for message count + if (! await this.isInboxEmptyTextVisible(TIMEOUT_10_SECONDS)) { + await expect(this.messageCount).toHaveText(/\d+\s+messages?/i, { timeout: TIMEOUT_60_SECONDS }); + } } private async exerciseQuickFilter() { @@ -511,6 +519,15 @@ export class StormboxPage { } } + private async isInboxEmptyTextVisible(timeout: number) { + try { + await expect(this.inboxEmptyText).toBeVisible({ timeout }); + return true; + } catch { + return false; + } + } + // BrowserStack can time out the goto event even after the login gate or app UI has rendered. private async didBrowserStackTimeoutAfterPageRendered(error: unknown) { const message = error instanceof Error ? error.message : String(error); From 5120f7ddb4824d793faf50719cd145f214bb42cd Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 11 Aug 2026 16:46:16 -0400 Subject: [PATCH 02/11] Update smoke test to exercise manage folders dialog --- tests/browserstack/const/constants.ts | 1 + tests/browserstack/pages/stormbox-page.ts | 44 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/browserstack/const/constants.ts b/tests/browserstack/const/constants.ts index 9d6d819..cc55295 100644 --- a/tests/browserstack/const/constants.ts +++ b/tests/browserstack/const/constants.ts @@ -10,6 +10,7 @@ export const PLAYWRIGHT_TAG_MOBILE = '@stormbox-mobile'; export const PLAYWRIGHT_TAG_DESKTOP_SMOKE = '@stormbox-smoke-desktop'; export const PLAYWRIGHT_TAG_MOBILE_SMOKE = '@stormbox-smoke-mobile'; +export const TIMEOUT_1_SECOND = 1_000; export const TIMEOUT_2_SECONDS = 2_000; export const TIMEOUT_5_SECONDS = 5_000; export const TIMEOUT_10_SECONDS = 10_000; diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 3b39e13..85c47ed 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -5,6 +5,7 @@ import { STORMBOX_BASE_URL, ACCTS_OIDC_PWORD, ACCTS_OIDC_EMAIL, + TIMEOUT_1_SECOND, TIMEOUT_2_SECONDS, TIMEOUT_10_SECONDS, TIMEOUT_30_SECONDS, @@ -62,6 +63,12 @@ export class StormboxPage { readonly welcomeDialog: Locator; readonly getStartedButton: Locator; readonly manageFoldersButton: Locator; + readonly manageFoldersDialog: Locator; + readonly manageFoldersDialogHdr: Locator; + readonly manageFoldersDialogText: Locator; + readonly manageFoldersDialogSearchInput: Locator; + readonly manageFoldersDialogCloseBtn: Locator; + readonly manageFoldersDialogExpandBtn: Locator; constructor(page: Page) { this.page = page; @@ -116,6 +123,12 @@ export class StormboxPage { this.welcomeDialog = page.getByRole('dialog', { name: /welcome to thundermail/i }); this.getStartedButton = page.getByRole('button', { name: /^get started$/i }); this.manageFoldersButton = page.getByRole('button', { name: 'Manage Folders' }); + this.manageFoldersDialog = page.getByRole('dialog', { name: 'Manage Folders' }); + this.manageFoldersDialogHdr = this.manageFoldersDialog.getByRole('heading', { name: 'Manage Folders', level: 2 }); + this.manageFoldersDialogText = this.manageFoldersDialog.getByText('Drag a folder to move it, or select several to delete them'); + this.manageFoldersDialogSearchInput = this.manageFoldersDialog.locator('.folder-subs__search-input'); + this.manageFoldersDialogCloseBtn = this.manageFoldersDialog.getByRole('button', { name: 'Close manage folders' }); + this.manageFoldersDialogExpandBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand default folders' }); } /** A bar action on desktop, or the same action as a compact-menu item. */ @@ -206,6 +219,7 @@ export class StormboxPage { await this.exerciseFolderListToggle(projectName); await this.exerciseComposeDialog(); await this.exerciseFolderNavigation(); + await this.exerciseManageFoldersDialog(); await this.exerciseContactsView(); await this.exerciseWelcomeModal(projectName); await this.assertExternalLinkOpensInNewTab(this.reportBugButton, BUG_REPORT_URL_PATTERN, projectName); @@ -306,6 +320,36 @@ export class StormboxPage { await this.page.waitForTimeout(TIMEOUT_2_SECONDS / 2); } + private async exerciseManageFoldersDialog() { + // we need the folders list panel to be open in order to click the manage folders button + // on mobile the folders list panel is closed by default, on desktop open by default + if (await this.showFolderListButton.isVisible().catch(() => false)) { + await this.showFolderList(); + } + + // now open the manage folders dialog and exercise the basic controls (just a smoke test) + // actually adding/modifying/deleting folders will be done in a separate folders test + await expect(this.manageFoldersButton).toBeVisible(); + await this.manageFoldersButton.click(); + await expect(this.manageFoldersDialog).toBeVisible(); + await expect(this.manageFoldersDialogHdr).toBeVisible(); + await expect(this.manageFoldersDialogText).toBeVisible(); + await expect(this.manageFoldersDialogSearchInput).toBeVisible(); + + // click the button to expand/view the default folders and verify + await this.manageFoldersDialogExpandBtn.click(); + await this.page.waitForTimeout(TIMEOUT_1_SECOND); // so can capture on BrowserStack video + for (const folderName of FOLDER_NAMES_TO_EXERCISE) { + await expect(this.page.locator('.folder-subs__name', { hasText: folderName })).toBeVisible(); + } + + // finished, close the manage folders dialog + await expect(this.manageFoldersDialogCloseBtn).toBeVisible(); + await this.manageFoldersDialogCloseBtn.click(); + await this.page.waitForTimeout(TIMEOUT_2_SECONDS); + await expect(this.manageFoldersDialog).not.toBeVisible(); + } + private async exerciseContactsView() { await expect(this.contactsSpaceButton).toBeVisible(); await this.contactsSpaceButton.click(); From 985dad259e38982db00ca50707efba9122b2f758 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Wed, 12 Aug 2026 13:51:02 -0400 Subject: [PATCH 03/11] Consolidate ui-smoke test into one test for both desktop and mobile --- tests/browserstack/README.md | 6 +-- tests/browserstack/package.json | 4 +- tests/browserstack/playwright.config.ts | 2 +- .../tests/{desktop => }/auth.desktop.ts | 2 +- .../tests/desktop/ui-smoke.desktop.spec.ts | 20 -------- .../tests/mobile/ui-smoke.mobile.spec.ts | 37 -------------- tests/browserstack/tests/ui-smoke.spec.ts | 49 +++++++++++++++++++ 7 files changed, 56 insertions(+), 64 deletions(-) rename tests/browserstack/tests/{desktop => }/auth.desktop.ts (91%) delete mode 100644 tests/browserstack/tests/desktop/ui-smoke.desktop.spec.ts delete mode 100644 tests/browserstack/tests/mobile/ui-smoke.mobile.spec.ts create mode 100644 tests/browserstack/tests/ui-smoke.spec.ts diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index 70da26b..a8e73ad 100644 --- a/tests/browserstack/README.md +++ b/tests/browserstack/README.md @@ -45,7 +45,7 @@ These commands run the UI smoke test on your machine against the deployed `STORM npm run e2e:desktop:firefox:smoke npm run e2e:desktop:chrome:smoke npm run e2e:desktop:safari:smoke -npm run e2e:mobile:google:pixel:viewport:smoke +npm run e2e:mobile:android:viewport:smoke ``` ## UI Smoke Test BrowserStack Runs @@ -67,7 +67,7 @@ These commands run all of the UI E2E tests on your machine against the deployed npm run e2e:desktop:firefox npm run e2e:desktop:chrome npm run e2e:desktop:safari -npm run e2e:mobile:google:pixel:viewport +npm run e2e:mobile:android:viewport ``` ## Entire Suite BrowserStack Runs @@ -81,4 +81,4 @@ npm run e2e:browserstack:desktop:safari npm run e2e:browserstack:mobile:android:chrome ``` -Desktop runs authenticate once in `tests/desktop/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. +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. diff --git a/tests/browserstack/package.json b/tests/browserstack/package.json index c9d0025..d1e669e 100644 --- a/tests/browserstack/package.json +++ b/tests/browserstack/package.json @@ -7,7 +7,7 @@ "e2e:desktop:firefox:smoke": "playwright test --grep @stormbox-smoke-desktop --project=firefox --headed", "e2e:desktop:chrome:smoke": "playwright test --grep @stormbox-smoke-desktop --project=chromium --headed", "e2e:desktop:safari:smoke": "playwright test --grep @stormbox-smoke-desktop --project=safari --headed", - "e2e:mobile:google:pixel:viewport:smoke": "playwright test --grep @stormbox-smoke-mobile --project=Google-Pixel-7-View --headed", + "e2e:mobile:android:viewport:smoke": "playwright test --grep @stormbox-smoke-mobile --project=android-viewport --headed", "e2e:browserstack:desktop:firefox:smoke": "browserstack-node-sdk playwright test --grep @stormbox-smoke-desktop --project=Firefox-OSX --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail UI Smoke Test Firefox Desktop'", "e2e:browserstack:desktop:chrome:smoke": "browserstack-node-sdk playwright test --grep @stormbox-smoke-desktop --project=Chromium-Win11 --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail UI Smoke Test Chromium Desktop'", "e2e:browserstack:desktop:safari:smoke": "browserstack-node-sdk playwright test --grep @stormbox-smoke-desktop --project=Safari-OSX --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail UI Smoke Test Safari Desktop'", @@ -15,7 +15,7 @@ "e2e:desktop:firefox": "playwright test --grep @stormbox-desktop --project=firefox --headed", "e2e:desktop:chrome": "playwright test --grep @stormbox-desktop --project=chromium --headed", "e2e:desktop:safari": "playwright test --grep @stormbox-desktop --project=safari --headed", - "e2e:mobile:google:pixel:viewport": "playwright test --grep @stormbox-mobile --project=Google-Pixel-7-View --headed", + "e2e:mobile:android:viewport": "playwright test --grep @stormbox-mobile --project=android-viewport --headed", "e2e:browserstack:desktop:firefox": "browserstack-node-sdk playwright test --grep @stormbox-desktop --project=Firefox-OSX --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail E2E Tests Firefox Desktop'", "e2e:browserstack:desktop:chrome": "browserstack-node-sdk playwright test --grep @stormbox-desktop --project=Chromium-Win11 --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail E2E Tests Chromium Desktop'", "e2e:browserstack:desktop:safari": "browserstack-node-sdk playwright test --grep @stormbox-desktop --project=Safari-OSX --browserstack.config browserstack-desktop.yml --browserstack.buildName 'Webmail E2E Tests Safari Desktop'", diff --git a/tests/browserstack/playwright.config.ts b/tests/browserstack/playwright.config.ts index 7707b25..00c0d9b 100644 --- a/tests/browserstack/playwright.config.ts +++ b/tests/browserstack/playwright.config.ts @@ -102,7 +102,7 @@ export default defineConfig({ /* Test against mobile viewports. */ { - name: 'Google-Pixel-7-View', + name: 'android-viewport', use: { ...devices['Pixel 7'], screenshot: 'only-on-failure', diff --git a/tests/browserstack/tests/desktop/auth.desktop.ts b/tests/browserstack/tests/auth.desktop.ts similarity index 91% rename from tests/browserstack/tests/desktop/auth.desktop.ts rename to tests/browserstack/tests/auth.desktop.ts index 3673cd2..4e1e387 100644 --- a/tests/browserstack/tests/desktop/auth.desktop.ts +++ b/tests/browserstack/tests/auth.desktop.ts @@ -1,6 +1,6 @@ import { test as setup } from '@playwright/test'; -import { initializeEmptyAuthStorage, ensureStormboxSignedIn } from '../../utils/auth'; +import { initializeEmptyAuthStorage, ensureStormboxSignedIn } from '../utils/auth'; initializeEmptyAuthStorage(); diff --git a/tests/browserstack/tests/desktop/ui-smoke.desktop.spec.ts b/tests/browserstack/tests/desktop/ui-smoke.desktop.spec.ts deleted file mode 100644 index 49f3b00..0000000 --- a/tests/browserstack/tests/desktop/ui-smoke.desktop.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from '@playwright/test'; - -import { PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_DESKTOP_SMOKE } from '../../const/constants'; -import { StormboxPage } from '../../pages/stormbox-page'; - -test.describe('stormbox desktop ui smoke', { - tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_DESKTOP_SMOKE], -}, () => { - test('verify and exercise basic webmail elements after signing in', async ({ page }) => { - const stormbox = new StormboxPage(page); - await stormbox.navigate(); - await stormbox.signInIfNeeded(); - await test.step('verify signed-in desktop ui is visible', async () => { - await stormbox.assertDesktopUiVisible(); - }); - await test.step('exercise common desktop ui controls', async () => { - await stormbox.exerciseCommonUiControls(); - }); - }); -}); diff --git a/tests/browserstack/tests/mobile/ui-smoke.mobile.spec.ts b/tests/browserstack/tests/mobile/ui-smoke.mobile.spec.ts deleted file mode 100644 index 28bc5b8..0000000 --- a/tests/browserstack/tests/mobile/ui-smoke.mobile.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test } from '@playwright/test'; - -import { PLAYWRIGHT_TAG_MOBILE, PLAYWRIGHT_TAG_MOBILE_SMOKE } from '../../const/constants'; -import { StormboxPage } from '../../pages/stormbox-page'; - -let stormbox: StormboxPage; - -test.describe('stormbox mobile ui smoke', { - tag: [PLAYWRIGHT_TAG_MOBILE, PLAYWRIGHT_TAG_MOBILE_SMOKE], -}, () => { - test.beforeEach(async ({ page }, testInfo) => { - stormbox = new StormboxPage(page); - await stormbox.navigate(); - - // Check if the current browser supports everything Stormbox needs - // For example, Stormbox uses SharedWorker which is supported on Android Chrome 148+ - // but not below; if the browser provided by BrowserStack is < 148 then skip - // (BrowserStack support confirmed they don't have Android Chrome 148+ available yet) - const missing = await stormbox.missingRequiredBrowserFeatures(); - test.skip( - missing.length > 0, - `Stormbox cannot run in this mobile browser. Missing: ${missing.join(', ')}.`, - ); - - await stormbox.signInIfNeeded(testInfo.project.name); - }); - - test('verify and exercise basic webmail elements after signing in', async ({ page }, testInfo) => { - await test.step('verify signed-in mobile ui is visible', async () => { - await stormbox.assertMobileUiVisible(testInfo.project.name); - }); - - await test.step('exercise common mobile ui controls', async () => { - await stormbox.exerciseCommonUiControls(testInfo.project.name); - }); - }); -}); diff --git a/tests/browserstack/tests/ui-smoke.spec.ts b/tests/browserstack/tests/ui-smoke.spec.ts new file mode 100644 index 0000000..12149d4 --- /dev/null +++ b/tests/browserstack/tests/ui-smoke.spec.ts @@ -0,0 +1,49 @@ +import { test } from '@playwright/test'; + +import { + PLAYWRIGHT_TAG_DESKTOP, + PLAYWRIGHT_TAG_DESKTOP_SMOKE, + PLAYWRIGHT_TAG_MOBILE, + PLAYWRIGHT_TAG_MOBILE_SMOKE +} from '../const/constants'; + +import { StormboxPage } from '../pages/stormbox-page'; + +let stormbox: StormboxPage; +let mobile: boolean = false; + +test.describe('stormbox ui smoke', { + tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_DESKTOP_SMOKE, PLAYWRIGHT_TAG_MOBILE, PLAYWRIGHT_TAG_MOBILE_SMOKE], +}, () => { + test.beforeEach(async ({ page }, testInfo) => { + mobile = testInfo.project.name.toLowerCase().includes('android'); + stormbox = new StormboxPage(page); + await stormbox.navigate(); + + // make sure browser has required dependencies; i.e. sharedworker is supported on Android Chrome 148+ only + const missing = await stormbox.missingRequiredBrowserFeatures(); + test.skip( + missing.length > 0, + `Stormbox cannot run in this mobile browser. Missing: ${missing.join(', ')}.`, + ); + + // on mobile we need to sign in each time (desktop uses auth.desktop and saves context) + if (mobile) { + await stormbox.signInIfNeeded(testInfo.project.name); + } + }); + + test('verify and exercise basic webmail elements after signing in', async ({ page }, testInfo) => { + await test.step('verify signed-in stormbox ui is visible', async () => { + if (mobile) { + await stormbox.assertMobileUiVisible(testInfo.project.name); + } else { + await stormbox.assertDesktopUiVisible(); + } + }); + + await test.step('exercise common stormbox ui controls', async () => { + await stormbox.exerciseCommonUiControls(testInfo.project.name); + }); + }); +}); From c87c1e85ea034548c2595b6977bf5bd6c6c9eadd Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Wed, 12 Aug 2026 15:04:18 -0400 Subject: [PATCH 04/11] Add folder management test for desktop and mobile, and update UI smoke test --- tests/browserstack/const/constants.ts | 1 - tests/browserstack/pages/stormbox-page.ts | 238 +++++++++---- .../tests/folder-management.spec.ts | 316 ++++++++++++++++++ 3 files changed, 491 insertions(+), 64 deletions(-) create mode 100644 tests/browserstack/tests/folder-management.spec.ts diff --git a/tests/browserstack/const/constants.ts b/tests/browserstack/const/constants.ts index cc55295..9d6d819 100644 --- a/tests/browserstack/const/constants.ts +++ b/tests/browserstack/const/constants.ts @@ -10,7 +10,6 @@ export const PLAYWRIGHT_TAG_MOBILE = '@stormbox-mobile'; export const PLAYWRIGHT_TAG_DESKTOP_SMOKE = '@stormbox-smoke-desktop'; export const PLAYWRIGHT_TAG_MOBILE_SMOKE = '@stormbox-smoke-mobile'; -export const TIMEOUT_1_SECOND = 1_000; export const TIMEOUT_2_SECONDS = 2_000; export const TIMEOUT_5_SECONDS = 5_000; export const TIMEOUT_10_SECONDS = 10_000; diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 85c47ed..1e66ffa 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -5,7 +5,6 @@ import { STORMBOX_BASE_URL, ACCTS_OIDC_PWORD, ACCTS_OIDC_EMAIL, - TIMEOUT_1_SECOND, TIMEOUT_2_SECONDS, TIMEOUT_10_SECONDS, TIMEOUT_30_SECONDS, @@ -31,6 +30,7 @@ export class StormboxPage { readonly mailboxesNav: Locator; readonly mailSpaceButton: Locator; readonly contactsSpaceButton: Locator; + readonly showAddressBookListButton: Locator; readonly messagesArea: Locator; readonly hideFolderListButton: Locator; readonly showFolderListButton: Locator; @@ -45,6 +45,8 @@ export class StormboxPage { readonly settingsGearButton: Locator; readonly settingsMenuItem: Locator; readonly settingsDialog: Locator; + readonly settingsCloseButton: Locator; + readonly systemThemeToggle: Locator; readonly showWelcomeButton: Locator; readonly selectAllMessagesCheckbox: Locator; readonly unreadFilterButton: Locator; @@ -54,7 +56,7 @@ export class StormboxPage { readonly loadingInboxMessage: Locator; readonly loadingMessageList: Locator; readonly composeDialog: Locator; - readonly discardComposeButton: Locator; + readonly closeComposeButton: Locator; readonly allContactsHeading: Locator; readonly addContactButton: Locator; readonly contactNameInput: Locator; @@ -64,11 +66,26 @@ export class StormboxPage { readonly getStartedButton: Locator; readonly manageFoldersButton: Locator; readonly manageFoldersDialog: Locator; - readonly manageFoldersDialogHdr: Locator; - readonly manageFoldersDialogText: Locator; - readonly manageFoldersDialogSearchInput: Locator; - readonly manageFoldersDialogCloseBtn: Locator; - readonly manageFoldersDialogExpandBtn: Locator; + readonly manageFoldersHdr: Locator; + readonly manageFoldersText: Locator; + readonly manageFoldersSearchInput: Locator; + readonly manageFoldersCloseBtn: Locator; + readonly manageFoldersExpandBtn: Locator; + readonly manageFoldersAddTopLevelBtn: Locator; + readonly manageFoldersNewFolderDialog: Locator; + readonly manageFoldersNewFolderNameInput: Locator; + readonly manageFoldersNewFolderParentSelect: Locator; + readonly manageFoldersNewFolderCreateBtn: Locator; + readonly manageFoldersNewFolderCancelBtn: Locator; + readonly manageFoldersNewFolderNameExistsText: Locator; + readonly manageFoldersExpandInboxBtn: Locator; + readonly manageFoldersRenameNameInput: Locator; + readonly manageFoldersMoveRenameSaveBtn: Locator; + readonly manageFoldersMoveParentSelect: Locator; + readonly foldersPanelExpandInboxBtn: Locator; + readonly manageFoldersDeleteSelectedFoldersBtn: Locator; + readonly manageFoldersDialogDeleteNFoldersText: Locator; + readonly manageFoldersDialogDeleteNFoldersConfirmBtn: Locator; constructor(page: Page) { this.page = page; @@ -89,6 +106,7 @@ export class StormboxPage { this.mailboxesNav = page.getByRole('navigation', { name: /mailboxes/i }); this.mailSpaceButton = page.getByRole('button', { name: /^mail$/i }); this.contactsSpaceButton = page.getByRole('button', { name: /^contacts$/i }); + this.showAddressBookListButton = page.getByRole('button', { name: /^show address book list$/i }); this.messagesArea = page.getByRole('region', { name: /^messages$/i }); this.hideFolderListButton = page.getByRole('button', { name: /^hide folder list$/i }); this.showFolderListButton = page.getByRole('button', { name: /^show folder list$/i }); @@ -103,6 +121,8 @@ export class StormboxPage { this.settingsGearButton = page.locator('[data-settings-gear]'); this.settingsMenuItem = page.getByRole('menuitem', { name: /^settings$/i }); this.settingsDialog = page.getByRole('dialog', { name: /^settings$/i }); + this.settingsCloseButton = this.settingsDialog.getByRole('button', { name: /^close settings$/i }); + this.systemThemeToggle = this.settingsDialog.locator('[data-system-theme-toggle]'); this.showWelcomeButton = this.settingsDialog.getByRole('button', { name: /^show welcome$/i }); this.selectAllMessagesCheckbox = page.locator('.msg-list__select-all input[type="checkbox"]'); this.unreadFilterButton = page.getByRole('button', { name: /^unread$/i }); @@ -113,22 +133,40 @@ export class StormboxPage { .filter({ hasText: /loading inbox/i }); this.loadingMessageList = page.locator('.msg-list__loader, .msg-list__placeholder') .filter({ hasText: /loading/i }); - this.composeDialog = page.getByRole('dialog', { name: /^compose$/i }); - this.discardComposeButton = page.getByRole('button', { name: /^discard$/i }); + this.composeDialog = page.getByRole('dialog', { name: /^new message$/i }); + this.closeComposeButton = this.composeDialog.getByRole('button', { name: /^close$/i }); this.allContactsHeading = page.getByRole('heading', { name: /^all contacts$/i }); - this.addContactButton = page.getByRole('button', { name: /^add contact$/i }); - this.contactNameInput = page.locator('.contacts__form input[type="text"]').first(); - this.contactEmailInput = page.locator('.contacts__form input[type="email"]').first(); + this.addContactButton = page.getByRole('button', { name: /^new contact$/i }); + this.contactNameInput = page.locator('.contacts__form').getByRole('textbox', { name: /^full or display name$/i }); + this.contactEmailInput = page.locator('.contacts__form').getByRole('textbox', { name: /^email addresses value$/i }); this.cancelContactButton = page.locator('.contacts__form').getByRole('button', { name: /^cancel$/i }); this.welcomeDialog = page.getByRole('dialog', { name: /welcome to thundermail/i }); this.getStartedButton = page.getByRole('button', { name: /^get started$/i }); this.manageFoldersButton = page.getByRole('button', { name: 'Manage Folders' }); this.manageFoldersDialog = page.getByRole('dialog', { name: 'Manage Folders' }); - this.manageFoldersDialogHdr = this.manageFoldersDialog.getByRole('heading', { name: 'Manage Folders', level: 2 }); - this.manageFoldersDialogText = this.manageFoldersDialog.getByText('Drag a folder to move it, or select several to delete them'); - this.manageFoldersDialogSearchInput = this.manageFoldersDialog.locator('.folder-subs__search-input'); - this.manageFoldersDialogCloseBtn = this.manageFoldersDialog.getByRole('button', { name: 'Close manage folders' }); - this.manageFoldersDialogExpandBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand default folders' }); + this.manageFoldersHdr = this.manageFoldersDialog.getByRole('heading', { name: 'Manage Folders', level: 2 }); + this.manageFoldersText = this.manageFoldersDialog.getByText('Drag a folder to move it, or select several to delete them'); + this.manageFoldersSearchInput = this.manageFoldersDialog.locator('.folder-subs__search-input'); + this.manageFoldersCloseBtn = this.manageFoldersDialog.getByRole('button', { name: 'Close manage folders' }); + this.manageFoldersExpandBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand default folders' }); + this.manageFoldersAddTopLevelBtn = this.manageFoldersDialog.getByRole('button', { name: 'New folder', exact: true }); + this.manageFoldersNewFolderDialog = page.getByRole('dialog', { name: 'New folder' }); + this.manageFoldersNewFolderNameInput = this.manageFoldersNewFolderDialog.getByRole('textbox', { name: 'Name' }); + this.manageFoldersNewFolderParentSelect = this.manageFoldersNewFolderDialog.locator('select[data-folder-create-parent]'); + this.manageFoldersNewFolderCreateBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Create' }); + this.manageFoldersNewFolderCancelBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Cancel' }); + this.manageFoldersNewFolderNameExistsText = this.manageFoldersNewFolderDialog.getByText('A folder with that name already exists here.', { exact: true }); + this.manageFoldersExpandInboxBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand inbox' }); + this.manageFoldersRenameNameInput = this.manageFoldersDialog.getByRole('textbox', { name: 'Name' }) + this.manageFoldersMoveParentSelect = this.manageFoldersDialog.locator('[data-folder-move-select]'); + this.manageFoldersMoveRenameSaveBtn = this.manageFoldersDialog.getByRole('button', { name: 'Save' }); + this.foldersPanelExpandInboxBtn = page.locator('.folder-node') + .filter({ has: page.getByText('Inbox', { exact: true }) }) + .filter({ has: page.getByRole('button', { name: 'Expand folder' }) }) + .getByRole('button', { name: 'Expand folder' }); + this.manageFoldersDeleteSelectedFoldersBtn = this.manageFoldersDialog.getByRole('button', { name: 'Delete selected folders' }); + this.manageFoldersDialogDeleteNFoldersText = this.manageFoldersDialog.locator('.folder-subs__bulk-confirm'); + this.manageFoldersDialogDeleteNFoldersConfirmBtn = this.manageFoldersDialog.locator('[data-folder-bulk-confirm]'); } /** A bar action on desktop, or the same action as a compact-menu item. */ @@ -220,7 +258,7 @@ export class StormboxPage { await this.exerciseComposeDialog(); await this.exerciseFolderNavigation(); await this.exerciseManageFoldersDialog(); - await this.exerciseContactsView(); + await this.exerciseContactsView(projectName); await this.exerciseWelcomeModal(projectName); await this.assertExternalLinkOpensInNewTab(this.reportBugButton, BUG_REPORT_URL_PATTERN, projectName); await this.assertExternalLinkOpensInNewTab(this.giveFeedbackButton, FEEDBACK_URL_PATTERN, projectName); @@ -231,16 +269,73 @@ export class StormboxPage { await this.withHeaderActions(projectName, async () => { await expect(this.reportBugButton).toBeVisible(); await expect(this.giveFeedbackButton).toBeVisible(); - await this.assertThemeToggleForCurrentModeVisible(); }); await this.assertAccountMenuItemsVisible(); await expect(this.selectAllMessagesCheckbox).toBeVisible(); + await expect(this.messageCount).toBeVisible(); await expect(this.unreadFilterButton).toBeVisible(); await expect(this.messageRefreshButton).toBeVisible(); + } + + async openManageFoldersDialog(projectName:string = 'desktop') { + // first check if the manage folders dialog is already open, if so exit + if (await this.manageFoldersDialog.isVisible().catch(() => true)) { + return; + } - // the inbox might have messages and might not; if there are messages check for message count - if (! await this.isInboxEmptyTextVisible(TIMEOUT_10_SECONDS)) { - await expect(this.messageCount).toHaveText(/\d+\s+messages?/i, { timeout: TIMEOUT_60_SECONDS }); + // first we need the folders list panel if it's not already open + if (await this.showFolderListButton.isVisible().catch(() => false)) { + await this.showFolderList(projectName); + } + // then click on manage folders button to open the dialog, and click to expand default folders list + await this.manageFoldersButton.click(); + await this.manageFoldersExpandBtn.click(); + await expect(this.manageFoldersDialog).toBeVisible(); + await expect(this.manageFoldersHdr).toBeVisible(); + } + + async closeManageFoldersDialog() { + await this.manageFoldersCloseBtn.click(); + await expect(this.manageFoldersDialog).not.toBeVisible(); + } + + async addFolder(fName: string, parentFolder: string, duplicate:boolean, projectName:string = 'desktop') { + console.log(`creating folder: '${fName}' in '${parentFolder}'`); + await this.openManageFoldersDialog(projectName); + await this.manageFoldersAddTopLevelBtn.scrollIntoViewIfNeeded(); + await this.manageFoldersAddTopLevelBtn.click(); + await expect(this.manageFoldersNewFolderDialog).toBeVisible(); + + await this.manageFoldersNewFolderNameInput.fill(fName); + + if (parentFolder == 'Top Level') { + // the parent selector options don't have a 'value' for 'Top Level' so if 'Top Level' just add by name + await this.manageFoldersNewFolderParentSelect.selectOption({ label: parentFolder }); + } else { + // when adding sub-folders a space is added to the front of the folder name in the select parent element + // so locate by 'hasText' so will ignore any leading spaces, and then select via the option value not text + // because all the folders except 'Top Level' have value attributes in each folder name in the select list + const value = await this.manageFoldersNewFolderParentSelect + .locator('option') + .filter({ hasText: parentFolder }) + .getAttribute('value'); + + if (value === null) { + throw new Error(`Could not find option for folder: ${parentFolder}`); + } + await this.manageFoldersNewFolderParentSelect.selectOption({ value }); + } + + // now we have the name and parent set, just click create + await this.manageFoldersNewFolderCreateBtn.click({ force: projectName.toLowerCase().includes('android')}); + + // if adding a folder with a duplicate name, expect the 'folder name exists' error and cancel out + // otherwise we expect the add new folder dialog to be closed after clicking create + if (duplicate) { + await expect(this.manageFoldersNewFolderNameExistsText).toBeVisible(); + await this.manageFoldersNewFolderCancelBtn.click({ force: projectName.toLowerCase().includes('android')}); + } else { + await expect(this.manageFoldersNewFolderDialog).not.toBeVisible(); } } @@ -301,7 +396,7 @@ export class StormboxPage { await expect(this.newMessageButton).toBeVisible(); await this.newMessageButton.click(); await expect(this.composeDialog).toBeVisible(); - await this.discardComposeButton.click(); + await this.closeComposeButton.click(); await expect(this.composeDialog).not.toBeVisible(); } @@ -320,53 +415,45 @@ export class StormboxPage { await this.page.waitForTimeout(TIMEOUT_2_SECONDS / 2); } - private async exerciseManageFoldersDialog() { - // we need the folders list panel to be open in order to click the manage folders button - // on mobile the folders list panel is closed by default, on desktop open by default - if (await this.showFolderListButton.isVisible().catch(() => false)) { - await this.showFolderList(); - } + private async exerciseManageFoldersDialog(projectName:string = 'desktop') { + // open the manage folders dialog (may need to open folders panel first) + await this.openManageFoldersDialog(projectName); + await expect(this.manageFoldersText).toBeVisible(); + await expect(this.manageFoldersSearchInput).toBeVisible(); - // now open the manage folders dialog and exercise the basic controls (just a smoke test) - // actually adding/modifying/deleting folders will be done in a separate folders test - await expect(this.manageFoldersButton).toBeVisible(); - await this.manageFoldersButton.click(); - await expect(this.manageFoldersDialog).toBeVisible(); - await expect(this.manageFoldersDialogHdr).toBeVisible(); - await expect(this.manageFoldersDialogText).toBeVisible(); - await expect(this.manageFoldersDialogSearchInput).toBeVisible(); - - // click the button to expand/view the default folders and verify - await this.manageFoldersDialogExpandBtn.click(); - await this.page.waitForTimeout(TIMEOUT_1_SECOND); // so can capture on BrowserStack video + // verify default folders (we already expanded the folders list in openManageFoldersDialog) for (const folderName of FOLDER_NAMES_TO_EXERCISE) { await expect(this.page.locator('.folder-subs__name', { hasText: folderName })).toBeVisible(); } // finished, close the manage folders dialog - await expect(this.manageFoldersDialogCloseBtn).toBeVisible(); - await this.manageFoldersDialogCloseBtn.click(); - await this.page.waitForTimeout(TIMEOUT_2_SECONDS); - await expect(this.manageFoldersDialog).not.toBeVisible(); + await this.closeManageFoldersDialog(); } - private async exerciseContactsView() { + private async exerciseContactsView(projectName:string = 'desktop') { await expect(this.contactsSpaceButton).toBeVisible(); await this.contactsSpaceButton.click(); await expect(this.allContactsHeading).toBeVisible(); + + // Android's single-column layout keeps New Contact in the hidden address-book sidebar. + if (projectName.toLowerCase().includes('android')) { + await expect(this.showAddressBookListButton).toBeVisible(); + await this.showAddressBookListButton.click({ force: true }); + } + await expect(this.addContactButton).toBeVisible(); await this.addContactButton.click(); await expect(this.contactNameInput).toBeVisible(); await expect(this.contactEmailInput).toBeVisible(); - await this.cancelContactButton.click(); + await this.cancelContactButton.click({ force: projectName.toLowerCase().includes('android')}); await expect(this.contactEmailInput).not.toBeVisible(); await this.mailSpaceButton.click(); await this.waitForAppUi(); } - private async showFolderList() { + async showFolderList(projectName:string = 'desktop') { if (await this.showFolderListButton.isVisible().catch(() => false)) { - await this.showFolderListButton.click(); + await this.showFolderListButton.click({ force: projectName.toLowerCase().includes('android')} ); } await expect(this.mailboxesNav).toBeVisible(); @@ -392,7 +479,7 @@ export class StormboxPage { } private isDesktopProject(projectName: string) { - return projectName.toLowerCase() === 'desktop'; + return !/(android|mobile)/i.test(projectName); } private escapeRegExp(value: string) { @@ -422,6 +509,7 @@ export class StormboxPage { } private async exerciseThemeToggle(projectName: string) { + await this.ensureManualThemeMode(projectName); const theme = await this.currentTheme(); const [toOther, back] = theme === 'light' ? [this.switchToDarkModeButton, this.switchToLightModeButton] @@ -477,15 +565,50 @@ export class StormboxPage { }); } - /** Settings is the gear in the spaces rail on desktop and a compact-menu item below 640px. */ - private async exerciseWelcomeModal(projectName: string) { + private async ensureManualThemeMode(projectName: string) { + // turn off 'follow system theme' setting so that the switch mode button appears + await this.openSettingsDialog(projectName); + await expect(this.systemThemeToggle).toBeVisible(); + + if (await this.isSystemThemeEnabled()) { + await this.systemThemeToggle.click(); + await expect(this.systemThemeToggle).toHaveAttribute('aria-checked', 'false'); + } + + await this.closeSettingsDialog(); + } + + private async openSettingsDialog(projectName: string) { + if (await this.settingsDialog.isVisible().catch(() => false)) { + return; + } + if (this.isDesktopProject(projectName)) { await expect(this.settingsGearButton).toBeVisible(); await this.settingsGearButton.click(); } else { await this.clickHeaderAction(projectName, this.settingsMenuItem); } + await expect(this.settingsDialog).toBeVisible(); + } + + private async closeSettingsDialog() { + if (!await this.settingsDialog.isVisible().catch(() => false)) { + return; + } + + await this.settingsCloseButton.click(); + await expect(this.settingsDialog).not.toBeVisible(); + } + + private async isSystemThemeEnabled() { + return (await this.systemThemeToggle.getAttribute('aria-checked')) === 'true'; + } + + /** Settings is the gear in the spaces rail on desktop and a compact-menu item below 640px. */ + private async exerciseWelcomeModal(projectName: string) { + await this.openSettingsDialog(projectName); await this.showWelcomeButton.click(); await expect(this.settingsDialog).not.toBeVisible(); await expect(this.welcomeDialog).toBeVisible(); @@ -509,17 +632,6 @@ export class StormboxPage { }); } - private async assertThemeToggleForCurrentModeVisible() { - const theme = await this.currentTheme(); - - if (theme === 'dark') { - await expect(this.switchToLightModeButton).toBeVisible(); - return; - } - - await expect(this.switchToDarkModeButton).toBeVisible(); - } - private async currentTheme() { const theme = await this.page.evaluate(() => { const root = document.documentElement; diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts new file mode 100644 index 0000000..17d7400 --- /dev/null +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -0,0 +1,316 @@ +import { test, expect } from '@playwright/test'; + +import { + PLAYWRIGHT_TAG_DESKTOP, + PLAYWRIGHT_TAG_MOBILE, + TIMEOUT_2_SECONDS, +} from '../const/constants'; + +import { StormboxPage } from '../pages/stormbox-page'; + +let stormbox: StormboxPage; +let mobile: boolean = false; +const fNamePrefix: string = `E2E-${Date.now()}`; + +test.describe('stormbox folder management', { + tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE], +}, () => { + test.beforeEach(async ({ page }, testInfo) => { + mobile = testInfo.project.name.toLowerCase().includes('android'); + stormbox = new StormboxPage(page); + await stormbox.navigate(); + + // make sure browser has required dependencies; i.e. sharedworker is supported on Android Chrome 148+ only + const missing = await stormbox.missingRequiredBrowserFeatures(); + test.skip( + missing.length > 0, + `Stormbox cannot run in this mobile browser. Missing: ${missing.join(', ')}.`, + ); + + // on mobile we need to sign in each time (desktop uses auth.desktop and saves context) + if (mobile) { + await stormbox.signInIfNeeded(testInfo.project.name); + } + }); + + test('add, rename, move, search, and delete folders', async ({ page }, testInfo) => { + let foldersToCleanUp: Array = []; + const onAndroid:boolean = testInfo.project.name.toLowerCase().includes('android'); + + await test.step('add folder (top-level)', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + const fName: string = `${fNamePrefix}-A`; + await stormbox.addFolder(fName, 'Top Level', false, testInfo.project.name); + + await expect( + page.locator('.folder-subs__name').getByText(fName, { exact: true }) + ).toBeVisible() + + foldersToCleanUp.push(fName); + await stormbox.closeManageFoldersDialog(); + }); + + await test.step('add folder (inbox subfolder)', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + const fName:string = `${fNamePrefix}-SUB`; + await stormbox.addFolder(fName, 'Inbox', false, testInfo.project.name); + // now we need to expand the Inbox folder to see the new subfolder + await stormbox.manageFoldersExpandInboxBtn.click(); + + await expect( + page.locator('.folder-subs__name').getByText(fName, { exact: true }) + ).toBeVisible(); + + foldersToCleanUp.push(fName); + await stormbox.closeManageFoldersDialog(); + }); + + await test.step('add multiple subfolders (one level)', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + // add new top-level folder + const ourTopFolder:string = `${fNamePrefix}-MULTI`; + await stormbox.addFolder(ourTopFolder, 'Top Level', false, testInfo.project.name); + foldersToCleanUp.push(ourTopFolder); + + // now add a bunch of subfolders inside that one, all the same level + const numSubFolders = 5; + var subFoldersCreated: Array = []; + for (let subNum = 1; subNum <= numSubFolders; subNum += 1) { + const nextFolder: string = `${fNamePrefix}-SUB-${subNum.toString().padStart(2, '0')}`; + await stormbox.addFolder(nextFolder, ourTopFolder, false, testInfo.project.name); + subFoldersCreated.push(nextFolder); + } + + // close the manage folders dialog, and verify all the new folders exist in the folders panel + await stormbox.closeManageFoldersDialog(); + + // open the folders panel if it's not already open (on mobile it will have been closed) + await stormbox.showFolderList(testInfo.project.name); + + // click on the new top-level folder on folders panel to expand it so we can see all the new subfolders + const folder = page.locator('.folder-node').filter({ + has: page.getByText(ourTopFolder, { exact: true }), + }); + await folder.getByRole('button', { name: 'Expand folder' }).click(); + + for (const nextSub of subFoldersCreated) { + const subFolderLocator = page.locator('.folder-node').filter({ + has: page.getByText(nextSub, { exact: true }), + }); + await expect(subFolderLocator).toBeVisible(); + } + }); + + await test.step('add multiple subfolder levels', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + // add new top-level folder + const ourTopFolder:string = `${fNamePrefix}-LVL-01`; + await stormbox.addFolder(ourTopFolder, 'Top Level', false, testInfo.project.name); + var lvlFoldersCreated: Array = []; + lvlFoldersCreated.push(ourTopFolder); + foldersToCleanUp.push(ourTopFolder); + + // now add a bunch of subfolders, each one inside the previous one to have multiple levels + const numSubFolderLevels = 4; + var lastFolderAdded = ourTopFolder; + + for (let subNum = 1; subNum <= numSubFolderLevels; subNum += 1) { + const nextFolder: string = `${fNamePrefix}-LVL-${(subNum +1).toString().padStart(2, '0')}`; + await stormbox.addFolder(nextFolder, lastFolderAdded, false, testInfo.project.name); + lvlFoldersCreated.push(nextFolder); + lastFolderAdded = nextFolder; + } + + // close the manage folders dialog, and verify all the new folders exist in the folders panel + await stormbox.closeManageFoldersDialog(); + + // open the folders panel if it's not already open (on mobile it will have been closed) + await stormbox.showFolderList(testInfo.project.name); + + // now click through each of our folder levels and verify they all exist + for (const nextLvl of lvlFoldersCreated) { + const subFolderLocator = page.locator('.folder-node').filter({ + has: page.getByText(nextLvl, { exact: true }), + }); + await expect(subFolderLocator).toBeVisible(); + + // the last subfolder won't have an expand button + if (nextLvl != lvlFoldersCreated.at(-1)) { + await subFolderLocator.getByRole('button', { name: 'Expand folder' }).click({ force: onAndroid }); + } + } + }); + + await test.step('rename folder', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + + // take the first folder that we created above and rename it + const origFName = foldersToCleanUp[0]; + await expect(page.locator('.folder-subs__name', { hasText: origFName })).toBeVisible(); + + // rename and verify + const newFName = `RENAMED ${origFName}`; + console.log(`renaming folder '${origFName}' to '${newFName}'`); + await page.getByRole( + 'button', + { name: `Edit folder ${origFName}` }).click({ force: onAndroid } + ); + + await stormbox.manageFoldersMoveRenameSaveBtn.scrollIntoViewIfNeeded(); + await stormbox.manageFoldersRenameNameInput.fill(newFName); + await stormbox.manageFoldersMoveRenameSaveBtn.click({ force: onAndroid }); + + await expect( + page.locator('.folder-subs__name').getByText(origFName, { exact: true }) + ).not.toBeVisible(); + + await expect( + page.locator('.folder-subs__name').getByText(newFName, { exact: true }) + ).toBeVisible(); + + foldersToCleanUp[0] = newFName; + await stormbox.closeManageFoldersDialog(); + }); + + await test.step('search for a folder', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + // search for one of the folders that was created earlier + const randomElement = Math.floor(Math.random() * (foldersToCleanUp.length)); + const folderNameToFind = foldersToCleanUp[randomElement] + console.log(`searching for folder: ${folderNameToFind}`); + await stormbox.manageFoldersSearchInput.fill(folderNameToFind); + await expect( + page.locator('.folder-subs__name').getByText(folderNameToFind, { exact: true }) + ).toBeVisible(); + await stormbox.closeManageFoldersDialog(); + }); + + await test.step('star a folder', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + const fName: string = `${fNamePrefix}-STAR`; + await stormbox.addFolder(fName, 'Top Level', false, testInfo.project.name); + + await expect( + page.locator('.folder-subs__name').getByText(fName, { exact: true }) + ).toBeVisible() + + foldersToCleanUp.push(fName); + + const starButton = page.getByRole('button', { + name: `Star folder ${fName}`, + }); + + // the folder is new so shouldn't be starred yet + expect(await starButton.getAttribute('aria-pressed')).toBe('false'); + + // star it and verify + console.log(`starring folder: ${fName}`); + await starButton.click(); + expect(await starButton.getAttribute('aria-pressed')).toBe('true'); + + // now close the manage folders dialog and then verify on folder panel our starred folder is first in the list + // we look for the first folder after the 'Folders' heading so we don't get the system folders by mistate + await stormbox.closeManageFoldersDialog(); + + // need the folders panel open (on mobile it may be closed) + if (await stormbox.showFolderListButton.isVisible().catch(() => false)) { + await stormbox.showFolderList(testInfo.project.name); + } + + const firstFolder = page + .getByRole('heading', { name: 'Folders', exact: true }) + .locator('xpath=..') + .locator('xpath=following-sibling::div[contains(@class, "folder-node")][1]'); + + await expect(firstFolder.locator('.folder-node__name')).toHaveText(fName); + }); + + await test.step('move a folder', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + // create a new top-level folder + const fName: string = `${fNamePrefix}-MOVE`; + await stormbox.addFolder(fName, 'Top Level', false, testInfo.project.name); + + // verify exists on main folders panel + await stormbox.closeManageFoldersDialog(); + await stormbox.showFolderList(testInfo.project.name); + const folderLocator = page.locator('.folder-node').filter({ + has: page.getByText(fName, { exact: true }), + }); + await expect(folderLocator).toBeVisible(); + + // move the new folder under the Inbox + console.log(`moving top-level folder ${fName} to be under the inbox`); + await stormbox.openManageFoldersDialog(testInfo.project.name); + const folderEditBtn = page.getByRole('button', { + name: `Edit folder ${fName}`, + exact: true, + }); + + await folderEditBtn.click({ force: onAndroid }); + await stormbox.manageFoldersMoveRenameSaveBtn.scrollIntoViewIfNeeded(); + await stormbox.manageFoldersMoveParentSelect.selectOption({ label: 'Inbox' }); + await stormbox.manageFoldersMoveRenameSaveBtn.click({ force: onAndroid }); + await stormbox.closeManageFoldersDialog(); + + // verify new folder is now not visible (because it's under Inbox which is not expanded) + await stormbox.showFolderList(testInfo.project.name); + await expect(folderLocator).not.toBeVisible(); + + // expand Inbox folder and verify folder is now under there + await stormbox.foldersPanelExpandInboxBtn.click({ force: onAndroid }); + await expect(folderLocator).toBeVisible(); + foldersToCleanUp.push(fName); + }); + + await test.step('delete folders', async () => { + await stormbox.openManageFoldersDialog(testInfo.project.name); + // let's delete all the folders this test has created (search for all folders with fNamePrefix + // and delete them all, then search again and verify all gone) + + // top-level folders + for (const nextFolder of foldersToCleanUp) { + // find the folder + await stormbox.manageFoldersSearchInput.fill(nextFolder); + await expect( + page.locator('.folder-subs__name').getByText(nextFolder, { exact: true }) + ).toBeVisible(); + + // select it + console.log(`selecting folder for deletion: ${nextFolder}`); + const nextFolderCheckbox = page.getByRole('checkbox', { + name: `Select folder ${nextFolder}`, + exact: true, + }); + await nextFolderCheckbox.click({ force: onAndroid }); + } + + // now we have all the folders selected to delete, so delete them + await stormbox.manageFoldersDeleteSelectedFoldersBtn.click({ force: onAndroid }); + // expect 'Delete N folders' text + await expect( + stormbox.manageFoldersDialogDeleteNFoldersText + ).toHaveText(/^\s*Delete\s+[1-9]\d*\s+folders\?\s*$/); + + const text = await stormbox.manageFoldersDialogDeleteNFoldersText.textContent(); + const match = text?.match(/Delete\s+(\d+)\s+folders/); + const folderCount = Number(match?.[1]); + + // click the confirm bulk delete button + console.log(`Deleting ${folderCount} folders`); + await stormbox.manageFoldersDialogDeleteNFoldersConfirmBtn.click({ force: onAndroid }); + await page.waitForTimeout(TIMEOUT_2_SECONDS); + + // search for the folders again, they should be gone + for (const nextFolder of foldersToCleanUp) { + console.log(`searching for folder: ${nextFolder}`); + await stormbox.manageFoldersSearchInput.fill(nextFolder); + await expect( + page.locator('.folder-subs__name').getByText(nextFolder, { exact: true }) + ).not.toBeVisible(); + } + + await stormbox.closeManageFoldersDialog(); + }); + }); +}); From 781abb3348c27aa67dd2c900efb63cc078e30747 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 15 Sep 2026 16:46:58 -0400 Subject: [PATCH 05/11] Update folder picker locator --- tests/browserstack/pages/stormbox-page.ts | 67 +++++++++++++------ .../tests/folder-management.spec.ts | 2 +- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 1e66ffa..89caaef 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -74,14 +74,14 @@ export class StormboxPage { readonly manageFoldersAddTopLevelBtn: Locator; readonly manageFoldersNewFolderDialog: Locator; readonly manageFoldersNewFolderNameInput: Locator; - readonly manageFoldersNewFolderParentSelect: Locator; + readonly manageFoldersNewFolderParentDropdown: Locator; readonly manageFoldersNewFolderCreateBtn: Locator; readonly manageFoldersNewFolderCancelBtn: Locator; readonly manageFoldersNewFolderNameExistsText: Locator; readonly manageFoldersExpandInboxBtn: Locator; readonly manageFoldersRenameNameInput: Locator; readonly manageFoldersMoveRenameSaveBtn: Locator; - readonly manageFoldersMoveParentSelect: Locator; + readonly manageFoldersMoveParentDropdown: Locator; readonly foldersPanelExpandInboxBtn: Locator; readonly manageFoldersDeleteSelectedFoldersBtn: Locator; readonly manageFoldersDialogDeleteNFoldersText: Locator; @@ -152,13 +152,13 @@ export class StormboxPage { this.manageFoldersAddTopLevelBtn = this.manageFoldersDialog.getByRole('button', { name: 'New folder', exact: true }); this.manageFoldersNewFolderDialog = page.getByRole('dialog', { name: 'New folder' }); this.manageFoldersNewFolderNameInput = this.manageFoldersNewFolderDialog.getByRole('textbox', { name: 'Name' }); - this.manageFoldersNewFolderParentSelect = this.manageFoldersNewFolderDialog.locator('select[data-folder-create-parent]'); + this.manageFoldersNewFolderParentDropdown = this.manageFoldersNewFolderDialog.locator('[data-folder-create-parent]'); this.manageFoldersNewFolderCreateBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Create' }); this.manageFoldersNewFolderCancelBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Cancel' }); this.manageFoldersNewFolderNameExistsText = this.manageFoldersNewFolderDialog.getByText('A folder with that name already exists here.', { exact: true }); this.manageFoldersExpandInboxBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand inbox' }); this.manageFoldersRenameNameInput = this.manageFoldersDialog.getByRole('textbox', { name: 'Name' }) - this.manageFoldersMoveParentSelect = this.manageFoldersDialog.locator('[data-folder-move-select]'); + this.manageFoldersMoveParentDropdown = this.manageFoldersDialog.locator('[data-folder-move-select]'); this.manageFoldersMoveRenameSaveBtn = this.manageFoldersDialog.getByRole('button', { name: 'Save' }); this.foldersPanelExpandInboxBtn = page.locator('.folder-node') .filter({ has: page.getByText('Inbox', { exact: true }) }) @@ -308,23 +308,12 @@ export class StormboxPage { await this.manageFoldersNewFolderNameInput.fill(fName); - if (parentFolder == 'Top Level') { - // the parent selector options don't have a 'value' for 'Top Level' so if 'Top Level' just add by name - await this.manageFoldersNewFolderParentSelect.selectOption({ label: parentFolder }); - } else { - // when adding sub-folders a space is added to the front of the folder name in the select parent element - // so locate by 'hasText' so will ignore any leading spaces, and then select via the option value not text - // because all the folders except 'Top Level' have value attributes in each folder name in the select list - const value = await this.manageFoldersNewFolderParentSelect - .locator('option') - .filter({ hasText: parentFolder }) - .getAttribute('value'); - - if (value === null) { - throw new Error(`Could not find option for folder: ${parentFolder}`); - } - await this.manageFoldersNewFolderParentSelect.selectOption({ value }); - } + await this.selectFolderParent( + this.manageFoldersNewFolderParentDropdown, + 'Parent folder', + parentFolder, + projectName, + ); // now we have the name and parent set, just click create await this.manageFoldersNewFolderCreateBtn.click({ force: projectName.toLowerCase().includes('android')}); @@ -339,6 +328,15 @@ export class StormboxPage { } } + async selectMoveFolderParent(parentFolder: string, projectName = 'desktop') { + await this.selectFolderParent( + this.manageFoldersMoveParentDropdown, + 'Move to parent', + parentFolder, + projectName, + ); + } + private async exerciseQuickFilter() { await expect(this.quickFilter).toBeVisible(); await this.quickFilter.fill(QUICK_FILTER_EXERCISE_TEXT); @@ -486,6 +484,33 @@ export class StormboxPage { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + private async selectFolderParent( + dropdown: Locator, + menuName: string, + parentFolder: string, + projectName: string, + ) { + const menu = dropdown.getByRole('menu', { name: menuName, exact: true }); + if (!await menu.isVisible()) { + await dropdown.locator('summary').click({ + force: projectName.toLowerCase().includes('android'), + }); + } + + const option = menu.getByRole('menuitemradio', { + name: new RegExp(`^\\s*${this.escapeRegExp(parentFolder)}\\s*$`, 'i'), + }); + await expect(option).toBeVisible(); + + if (projectName.toLowerCase().includes('android')) { + // Android can position compact dropdown options outside the pointer viewport. + await option.dispatchEvent('click'); + return; + } + + await option.click(); + } + private async assertExternalLinkOpensInNewTab(link: Locator, expectedUrl: RegExp, projectName: string) { await this.openHeaderActions(projectName); await expect(link).toBeVisible(); diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts index 17d7400..17ce353 100644 --- a/tests/browserstack/tests/folder-management.spec.ts +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -249,7 +249,7 @@ test.describe('stormbox folder management', { await folderEditBtn.click({ force: onAndroid }); await stormbox.manageFoldersMoveRenameSaveBtn.scrollIntoViewIfNeeded(); - await stormbox.manageFoldersMoveParentSelect.selectOption({ label: 'Inbox' }); + await stormbox.selectMoveFolderParent('Inbox', testInfo.project.name); await stormbox.manageFoldersMoveRenameSaveBtn.click({ force: onAndroid }); await stormbox.closeManageFoldersDialog(); From 7f332a0937edf83231db1681c0ef6b61d6ed02c3 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Mon, 21 Sep 2026 17:08:31 -0400 Subject: [PATCH 06/11] Improve test clean-up by using a new JMAP prod/stage client --- .../.env.browserstack.prod.example | 6 + .../.env.browserstack.stage.example | 6 + tests/browserstack/README.md | 9 +- tests/browserstack/const/constants.ts | 9 + tests/browserstack/{utils => helpers}/auth.ts | 0 tests/browserstack/helpers/jmap-client.ts | 373 ++++++++++++++++++ tests/browserstack/package.json | 2 +- tests/browserstack/pages/stormbox-page.ts | 54 ++- tests/browserstack/tests/auth.desktop.ts | 2 +- .../tests/folder-management.spec.ts | 102 ++--- 10 files changed, 489 insertions(+), 74 deletions(-) rename tests/browserstack/{utils => helpers}/auth.ts (100%) create mode 100644 tests/browserstack/helpers/jmap-client.ts diff --git a/tests/browserstack/.env.browserstack.prod.example b/tests/browserstack/.env.browserstack.prod.example index 47fbc1a..34d30a4 100644 --- a/tests/browserstack/.env.browserstack.prod.example +++ b/tests/browserstack/.env.browserstack.prod.example @@ -3,5 +3,11 @@ STORMBOX_BASE_URL=https://webmail.thundermail.com/ ACCTS_OIDC_EMAIL="Thundermail username" ACCTS_OIDC_PWORD="Thundermail password" PRIMARY_THUNDERMAIL_EMAIL="primary Thundermail email address" + +# Direct JMAP cleanup uses the dedicated account's app password, not ACCTS_OIDC_PWORD. +THUNDERMAIL_JMAP_USERNAME="Thundermail JMAP username" +THUNDERMAIL_JMAP_APP_PASSWORD="Thundermail app password" +THUNDERMAIL_JMAP_URL="production Thundermail JMAP URL" + BROWSERSTACK_USERNAME="browserstack account user name" BROWSERSTACK_ACCESS_KEY="corresponding browserstack access key" diff --git a/tests/browserstack/.env.browserstack.stage.example b/tests/browserstack/.env.browserstack.stage.example index c0f084d..7d266e6 100644 --- a/tests/browserstack/.env.browserstack.stage.example +++ b/tests/browserstack/.env.browserstack.stage.example @@ -3,5 +3,11 @@ STORMBOX_BASE_URL=https://webmail.stage-thundermail.com/ ACCTS_OIDC_EMAIL="Thundermail username" ACCTS_OIDC_PWORD="Thundermail password" PRIMARY_THUNDERMAIL_EMAIL="primary Thundermail email address" + +# Direct JMAP cleanup uses the dedicated account's app password, not ACCTS_OIDC_PWORD. +THUNDERMAIL_JMAP_USERNAME="Thundermail JMAP username" +THUNDERMAIL_JMAP_APP_PASSWORD="Thundermail app password" +THUNDERMAIL_JMAP_URL="stage Thundermail JMAP URL" + BROWSERSTACK_USERNAME="browserstack account user name" BROWSERSTACK_ACCESS_KEY="corresponding browserstack access key" diff --git a/tests/browserstack/README.md b/tests/browserstack/README.md index a8e73ad..e8cf1bd 100644 --- a/tests/browserstack/README.md +++ b/tests/browserstack/README.md @@ -1,8 +1,8 @@ # Stormbox BrowserStack E2E Tests -This package contains UI-only Playwright tests for deployed Stormbox stage and production. These tests run against the public Stormbox web application on your local machine or in BrowserStack. +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. -These tests are not for the local Stormbox stack. The local-stack integration tests live in `../e2e`; those tests use JMAP helpers, database reads, local stack setup, and cache assertions. Use this package when you want to verify the deployed UI only. +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. ## Setup @@ -31,11 +31,14 @@ Fill in these values in `.env.browserstack`: ACCTS_OIDC_EMAIL="Thundermail username" ACCTS_OIDC_PWORD="Thundermail password" PRIMARY_THUNDERMAIL_EMAIL="primary Thundermail email address" +THUNDERMAIL_JMAP_USERNAME="Thundermail JMAP username" +THUNDERMAIL_JMAP_APP_PASSWORD="Thundermail app password created in TB Accounts" +THUNDERMAIL_JMAP_URL="deployed Thundermail JMAP URL" BROWSERSTACK_USERNAME="browserstack account user name" BROWSERSTACK_ACCESS_KEY="corresponding browserstack access key" ``` -The `.env.browserstack` file contains credentials and must stay local. +The `.env.browserstack` file contains credentials and must stay local. `ACCTS_OIDC_PWORD` signs into the Stormbox UI; `THUNDERMAIL_JMAP_APP_PASSWORD` is the separate app password used only for direct JMAP cleanup. ## UI Smoke Test Local Runs diff --git a/tests/browserstack/const/constants.ts b/tests/browserstack/const/constants.ts index 9d6d819..1b555ef 100644 --- a/tests/browserstack/const/constants.ts +++ b/tests/browserstack/const/constants.ts @@ -5,6 +5,15 @@ export const ACCTS_OIDC_EMAIL = String(process.env.ACCTS_OIDC_EMAIL ?? ''); export const ACCTS_OIDC_PWORD = String(process.env.ACCTS_OIDC_PWORD ?? ''); export const PRIMARY_THUNDERMAIL_EMAIL = String(process.env.PRIMARY_THUNDERMAIL_EMAIL ?? ''); +// Direct JMAP access is used only by BrowserStack test-data cleanup against +// deployed stage or production Thundermail. The password is the dedicated +// account's Thundermail app password, not its TB Accounts sign-in password. +export const THUNDERMAIL_JMAP_USERNAME = String(process.env.THUNDERMAIL_JMAP_USERNAME ?? ''); +export const THUNDERMAIL_JMAP_APP_PASSWORD = String( + process.env.THUNDERMAIL_JMAP_APP_PASSWORD ?? '', +); +export const THUNDERMAIL_JMAP_URL = String(process.env.THUNDERMAIL_JMAP_URL ?? ''); + export const PLAYWRIGHT_TAG_DESKTOP = '@stormbox-desktop'; export const PLAYWRIGHT_TAG_MOBILE = '@stormbox-mobile'; export const PLAYWRIGHT_TAG_DESKTOP_SMOKE = '@stormbox-smoke-desktop'; diff --git a/tests/browserstack/utils/auth.ts b/tests/browserstack/helpers/auth.ts similarity index 100% rename from tests/browserstack/utils/auth.ts rename to tests/browserstack/helpers/auth.ts diff --git a/tests/browserstack/helpers/jmap-client.ts b/tests/browserstack/helpers/jmap-client.ts new file mode 100644 index 0000000..375b3fd --- /dev/null +++ b/tests/browserstack/helpers/jmap-client.ts @@ -0,0 +1,373 @@ +/** + * Minimal JMAP client for BrowserStack cleanup against a deployed + * Thundermail stage or production account. + * + * This client deliberately does not share the local-stack JMAP helper in + * tests/e2e. Deployed Thundermail authenticates these direct JMAP requests + * with the test account's app password over HTTP Basic authentication; the + * 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. + */ + +import { + PRIMARY_THUNDERMAIL_EMAIL, + STORMBOX_TARGET_ENV, + THUNDERMAIL_JMAP_APP_PASSWORD, + THUNDERMAIL_JMAP_URL, + THUNDERMAIL_JMAP_USERNAME, +} from '../const/constants'; + +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 REQUEST_TIMEOUT_MS = 30_000; + +type JmapMethodCall = [string, Record, string]; +type JmapMethodResponse = [string, Record, string]; + +interface JmapPayload { + methodResponses?: JmapMethodResponse[]; +} + +interface JmapSession { + apiUrl?: string; + primaryAccounts?: Record; +} + +interface JmapClient { + accountId: string; + apiUrl: string; + authHeader: string; + identityAccountId: string; +} + +interface JmapMailbox { + id: string; + name: string; + parentId: string | null; + role: string | null; +} + +/** Read a required setting without including its secret value in an error. */ +function requireSetting(name: string, value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${name} must be set in tests/browserstack/.env.browserstack`); + } + return trimmed; +} + +/** + * Parse one JSON response with normal TLS verification and a finite timeout. + * Mutating requests are intentionally not retried: a lost response does not + * prove that the server failed to apply the mutation. + */ +async function fetchJson(url: string, init: RequestInit, description: string): Promise { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = await response.text(); + + if (!response.ok) { + throw new Error( + `${description} failed: ${response.status} ${response.statusText}` + + (body ? `; response=${body.slice(0, 500)}` : ''), + ); + } + + try { + return JSON.parse(body) as unknown; + } catch (error) { + throw new Error(`${description} returned invalid JSON`, { cause: error }); + } +} + +/** Send one JMAP request and surface protocol-level method errors. */ +async function jmapRequest( + client: JmapClient, + methodCalls: JmapMethodCall[], + using: string[] = [CORE_CAPABILITY, MAIL_CAPABILITY], +): Promise { + const payload = await fetchJson( + client.apiUrl, + { + method: 'POST', + headers: { + Authorization: client.authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ using, methodCalls }), + }, + 'JMAP request', + ) as JmapPayload; + + if (!Array.isArray(payload.methodResponses)) { + throw new Error('JMAP response did not contain methodResponses'); + } + + const methodError = payload.methodResponses.find(([name]) => name === 'error'); + if (methodError) { + throw new Error(`JMAP method error: ${JSON.stringify(methodError[1])}`); + } + + return payload; +} + +/** Return the response arguments for a named JMAP method. */ +function responseFor(payload: JmapPayload, methodName: string): Record { + const response = payload.methodResponses?.find(([name]) => name === methodName); + if (!response) { + throw new Error(`JMAP response did not contain ${methodName}`); + } + return response[1]; +} + +/** + * Confirm that the app password opened the dedicated account expected by the + * BrowserStack suite before any destructive request is allowed. + */ +async function verifyExpectedIdentity(client: JmapClient): Promise { + const expectedEmail = requireSetting('PRIMARY_THUNDERMAIL_EMAIL', PRIMARY_THUNDERMAIL_EMAIL) + .toLowerCase(); + const payload = await jmapRequest( + client, + [[ + 'Identity/get', + { + accountId: client.identityAccountId, + properties: ['email'], + }, + 'identity', + ]], + [CORE_CAPABILITY, SUBMISSION_CAPABILITY], + ); + const identities = responseFor(payload, 'Identity/get').list; + const hasExpectedIdentity = Array.isArray(identities) && identities.some((identity) => { + if (typeof identity !== 'object' || identity == null) return false; + const email = (identity as Record).email; + return typeof email === 'string' && email.toLowerCase() === expectedEmail; + }); + + if (!hasExpectedIdentity) { + throw new Error( + `JMAP credentials do not provide the expected ${PRIMARY_THUNDERMAIL_EMAIL} identity; refusing cleanup`, + ); + } +} + +/** + * Connect to the deployed Thundermail JMAP server with the dedicated test + * account's app password. This runs in the Node Playwright process, not in + * the remote BrowserStack browser, so credentials never enter page state. + */ +async function connectJmap(): Promise { + const targetEnvironment = requireSetting('STORMBOX_TARGET_ENV', STORMBOX_TARGET_ENV); + if (targetEnvironment !== 'stage' && targetEnvironment !== 'prod') { + throw new Error( + `Direct BrowserStack JMAP cleanup only supports deployed stage or prod environments, not "${targetEnvironment}"`, + ); + } + + const baseUrl = requireSetting('THUNDERMAIL_JMAP_URL', THUNDERMAIL_JMAP_URL).replace(/\/$/, ''); + const parsedBaseUrl = new URL(baseUrl); + if (parsedBaseUrl.protocol !== 'https:') { + throw new Error('THUNDERMAIL_JMAP_URL must use HTTPS for deployed Thundermail cleanup'); + } + + const username = requireSetting('THUNDERMAIL_JMAP_USERNAME', THUNDERMAIL_JMAP_USERNAME); + const appPassword = requireSetting( + 'THUNDERMAIL_JMAP_APP_PASSWORD', + THUNDERMAIL_JMAP_APP_PASSWORD, + ); + const authHeader = `Basic ${Buffer.from(`${username}:${appPassword}`, 'utf8').toString('base64')}`; + console.log(`connecting directly to the Thundermail JMAP server at ${baseUrl}`); + const session = await fetchJson( + `${baseUrl}/.well-known/jmap`, + { headers: { Authorization: authHeader } }, + 'JMAP session discovery', + ) as JmapSession; + + const accountId = session.primaryAccounts?.[MAIL_CAPABILITY]; + if (!accountId) { + throw new Error('JMAP session did not advertise a primary mail account'); + } + if (!session.apiUrl) { + throw new Error('JMAP session did not advertise an apiUrl'); + } + + // Resolve a relative apiUrl if a conforming proxy returns one, while using + // the server-advertised origin for normal production sessions. + const apiUrl = new URL(session.apiUrl, `${baseUrl}/`).toString(); + if (new URL(apiUrl).protocol !== 'https:') { + throw new Error('The deployed JMAP session advertised a non-HTTPS apiUrl'); + } + + const client: JmapClient = { + accountId, + apiUrl, + authHeader, + identityAccountId: session.primaryAccounts?.[SUBMISSION_CAPABILITY] ?? accountId, + }; + await verifyExpectedIdentity(client); + return client; +} + +/** Fetch the complete mailbox tree needed to delete children before parents. */ +async function listMailboxes(client: JmapClient): Promise { + const payload = await jmapRequest(client, [[ + 'Mailbox/get', + { + accountId: client.accountId, + ids: null, + properties: ['id', 'name', 'parentId', 'role'], + }, + 'mailboxes', + ]]); + const list = responseFor(payload, 'Mailbox/get').list; + if (!Array.isArray(list)) { + throw new Error('Mailbox/get response did not contain a mailbox list'); + } + + return list.map((mailbox) => { + if (typeof mailbox !== 'object' || mailbox == null) { + throw new Error('Mailbox/get returned a malformed mailbox'); + } + const row = mailbox as Record; + if (typeof row.id !== 'string' || typeof row.name !== 'string') { + throw new Error('Mailbox/get returned a mailbox without a string id and name'); + } + return { + id: row.id, + name: row.name, + parentId: typeof row.parentId === 'string' ? row.parentId : null, + role: typeof row.role === 'string' ? row.role : null, + }; + }); +} + +/** + * Refuse a partial subtree deletion. RFC 8621 requires children to be + * removed before their parent; an unmatched descendant also indicates that + * the requested prefix would reach data this cleanup does not own. + */ +function assertMatchingSubtrees(mailboxes: JmapMailbox[], matchingIds: Set): void { + const byId = new Map(mailboxes.map((mailbox) => [mailbox.id, mailbox])); + + for (const mailbox of mailboxes) { + if (matchingIds.has(mailbox.id)) continue; + const seen = new Set(); + let parentId = mailbox.parentId; + while (parentId) { + if (matchingIds.has(parentId)) { + throw new Error( + `Refusing to delete a matching folder that contains nonmatching child "${mailbox.name}"`, + ); + } + if (seen.has(parentId)) { + throw new Error('Mailbox/get returned a cyclic folder hierarchy'); + } + seen.add(parentId); + parentId = byId.get(parentId)?.parentId ?? null; + } + } +} + +/** Delete one leaf level and require every requested id to succeed. */ +async function deleteMailboxIds(client: JmapClient, mailboxes: JmapMailbox[]): Promise { + const ids = mailboxes.map((mailbox) => mailbox.id); + const payload = await jmapRequest(client, [[ + 'Mailbox/set', + { + accountId: client.accountId, + destroy: ids, + // Test-created folders should be empty. Preserve mail and surface an + // unexpected mailboxHasEmail response instead of deleting messages. + onDestroyRemoveEmails: false, + }, + 'deleteMailboxes', + ]]); + const result = responseFor(payload, 'Mailbox/set'); + const notDestroyed = result.notDestroyed; + if (typeof notDestroyed === 'object' && notDestroyed != null + && Object.keys(notDestroyed).length > 0) { + throw new Error(`Could not delete test folders: ${JSON.stringify(notDestroyed)}`); + } + + const destroyed = new Set(Array.isArray(result.destroyed) ? result.destroyed : []); + const missing = mailboxes.filter((mailbox) => !destroyed.has(mailbox.id)); + if (missing.length > 0) { + throw new Error( + `Mailbox/set did not confirm deletion of: ${missing.map((mailbox) => mailbox.name).join(', ')}`, + ); + } +} + +/** + * Delete every non-system folder whose name begins with the supplied test + * prefix, deepest-first, and verify the server no longer returns a match. + */ +export async function deleteFoldersByPrefix(prefix: string): Promise { + if (!prefix.startsWith('E2E-')) { + throw new Error('Folder cleanup prefix must begin with "E2E-"'); + } + + const client = await connectJmap(); + const allMailboxes = await listMailboxes(client); + const matching = allMailboxes.filter((mailbox) => mailbox.name.startsWith(prefix)); + if (matching.length === 0) { + console.log(`found 0 test folders to clean up with prefix "${prefix}"`); + return 0; + } + + const protectedMatch = matching.find((mailbox) => mailbox.role != null); + if (protectedMatch) { + throw new Error( + `Refusing to delete system folder "${protectedMatch.name}" with role "${protectedMatch.role}"`, + ); + } + + const matchingIds = new Set(matching.map((mailbox) => mailbox.id)); + assertMatchingSubtrees(allMailboxes, matchingIds); + console.log(`deleting ${matching.length} folder(s) using JMAP`); + + // Delete every current leaf level together, then repeat. This satisfies + // RFC 8621's mailboxHasChild constraint without relying on server ordering + // within one Mailbox/set destroy array. + const remainingTree = new Map(allMailboxes.map((mailbox) => [mailbox.id, mailbox])); + const remainingMatches = new Map(matching.map((mailbox) => [mailbox.id, mailbox])); + while (remainingMatches.size > 0) { + const parentIds = new Set( + [...remainingTree.values()] + .map((mailbox) => mailbox.parentId) + .filter((parentId): parentId is string => parentId != null), + ); + const leaves = [...remainingMatches.values()] + .filter((mailbox) => !parentIds.has(mailbox.id)); + if (leaves.length === 0) { + throw new Error('Could not find a leaf in the matching folder hierarchy'); + } + + console.log( + `Deleting test folders through JMAP: ${leaves.map((folder) => folder.name).join(', ')}`, + ); + await deleteMailboxIds(client, leaves); + for (const leaf of leaves) { + remainingTree.delete(leaf.id); + remainingMatches.delete(leaf.id); + } + } + + const leftovers = (await listMailboxes(client)) + .filter((mailbox) => mailbox.name.startsWith(prefix)); + if (leftovers.length > 0) { + throw new Error( + `JMAP cleanup left matching folders: ${leftovers.map((mailbox) => mailbox.name).join(', ')}`, + ); + } + + return matching.length; +} diff --git a/tests/browserstack/package.json b/tests/browserstack/package.json index d1e669e..0940057 100644 --- a/tests/browserstack/package.json +++ b/tests/browserstack/package.json @@ -2,7 +2,7 @@ "name": "stormbox-browserstack-e2e", "version": "1.0.0", "private": true, - "description": "UI-only Playwright and BrowserStack tests for deployed Stormbox.", + "description": "Playwright and BrowserStack tests for deployed Stormbox.", "scripts": { "e2e:desktop:firefox:smoke": "playwright test --grep @stormbox-smoke-desktop --project=firefox --headed", "e2e:desktop:chrome:smoke": "playwright test --grep @stormbox-smoke-desktop --project=chromium --headed", diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 89caaef..6527499 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -48,6 +48,7 @@ export class StormboxPage { readonly settingsCloseButton: Locator; readonly systemThemeToggle: Locator; readonly showWelcomeButton: Locator; + readonly messageListHeader: Locator; readonly selectAllMessagesCheckbox: Locator; readonly unreadFilterButton: Locator; readonly inboxEmptyText: Locator; @@ -124,6 +125,7 @@ export class StormboxPage { this.settingsCloseButton = this.settingsDialog.getByRole('button', { name: /^close settings$/i }); this.systemThemeToggle = this.settingsDialog.locator('[data-system-theme-toggle]'); this.showWelcomeButton = this.settingsDialog.getByRole('button', { name: /^show welcome$/i }); + this.messageListHeader = page.locator('.msg-list__header'); this.selectAllMessagesCheckbox = page.locator('.msg-list__select-all input[type="checkbox"]'); this.unreadFilterButton = page.getByRole('button', { name: /^unread$/i }); this.inboxEmptyText = page.getByText('Inbox is empty'); @@ -272,7 +274,7 @@ export class StormboxPage { }); await this.assertAccountMenuItemsVisible(); await expect(this.selectAllMessagesCheckbox).toBeVisible(); - await expect(this.messageCount).toBeVisible(); + await expect(this.messageListHeader).toBeVisible(); await expect(this.unreadFilterButton).toBeVisible(); await expect(this.messageRefreshButton).toBeVisible(); } @@ -337,6 +339,56 @@ export class StormboxPage { ); } + async delFoldersWithGivenPrefix(fNamePrefix: string, projectName = 'desktop') { + // search for all folders with the given prefix and delete them + const onAndroid = projectName.toLowerCase().includes('android'); + await this.openManageFoldersDialog(projectName); + await this.manageFoldersSearchInput.fill(fNamePrefix); + + // select them all + var folderCheckboxes = this.manageFoldersDialog.locator( + 'input[data-folder-select]', + ); + + const folderCount = await folderCheckboxes.count(); + console.log(`found ${folderCount} folders to delete`); + + if (folderCount > 0) { + await expect(folderCheckboxes.first()).toBeVisible(); + + for (let index = 0; index < folderCount; index += 1) { + const checkbox = folderCheckboxes.nth(index); + const folderName = await checkbox.getAttribute('data-folder-select'); + console.log(`Ensuring folder is selected for deletion: ${folderName}`); + await checkbox.check({ force: onAndroid }); + } + + // now we have all the folders selected to delete, so delete them + console.log(`bulk deleting ${folderCount} folders`); + await this.manageFoldersDeleteSelectedFoldersBtn.click({ force: onAndroid }); + await this.manageFoldersDialogDeleteNFoldersConfirmBtn.click({ force: onAndroid }); + // wait for the bulkbar to go away (folders deleted) + await expect(this.manageFoldersDialog.locator('[data-folder-bulkbar]')).toBeHidden({ timeout: 15_000 }); + await this.closeManageFoldersDialog(); + + // now let's verify no folders with our prefix exist anymore + await this.openManageFoldersDialog(projectName); + + await this.manageFoldersSearchInput.fill(fNamePrefix); + await expect(this.manageFoldersSearchInput).toHaveValue(fNamePrefix); + + const remainingTestFolders = this.manageFoldersDialog.locator( + `input[data-folder-select^="${fNamePrefix}"]`, + ); + + await expect(remainingTestFolders).toHaveCount(0, { + timeout: 15_000, + }); + } + + await this.closeManageFoldersDialog(); + } + private async exerciseQuickFilter() { await expect(this.quickFilter).toBeVisible(); await this.quickFilter.fill(QUICK_FILTER_EXERCISE_TEXT); diff --git a/tests/browserstack/tests/auth.desktop.ts b/tests/browserstack/tests/auth.desktop.ts index 4e1e387..8e318b6 100644 --- a/tests/browserstack/tests/auth.desktop.ts +++ b/tests/browserstack/tests/auth.desktop.ts @@ -1,6 +1,6 @@ import { test as setup } from '@playwright/test'; -import { initializeEmptyAuthStorage, ensureStormboxSignedIn } from '../utils/auth'; +import { initializeEmptyAuthStorage, ensureStormboxSignedIn } from '../helpers/auth'; initializeEmptyAuthStorage(); diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts index 17ce353..f608918 100644 --- a/tests/browserstack/tests/folder-management.spec.ts +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -3,11 +3,13 @@ import { test, expect } from '@playwright/test'; import { PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE, - TIMEOUT_2_SECONDS, } from '../const/constants'; +import { deleteFoldersByPrefix } from '../helpers/jmap-client'; + import { StormboxPage } from '../pages/stormbox-page'; + let stormbox: StormboxPage; let mobile: boolean = false; const fNamePrefix: string = `E2E-${Date.now()}`; @@ -16,8 +18,8 @@ test.describe('stormbox folder management', { tag: [PLAYWRIGHT_TAG_DESKTOP, PLAYWRIGHT_TAG_MOBILE], }, () => { test.beforeEach(async ({ page }, testInfo) => { - mobile = testInfo.project.name.toLowerCase().includes('android'); stormbox = new StormboxPage(page); + mobile = testInfo.project.name.toLowerCase().includes('android'); await stormbox.navigate(); // make sure browser has required dependencies; i.e. sharedworker is supported on Android Chrome 148+ only @@ -31,10 +33,16 @@ test.describe('stormbox folder management', { if (mobile) { await stormbox.signInIfNeeded(testInfo.project.name); } + + // The BrowserStack-only JMAP client connects directly to the deployed + // stage or production Thundermail account with its app password. Remove + // orphaned test folders before exercising the UI with a clean account. + console.log('deleting any folders leftover from previous test runs'); + const deletedFolderCount = await deleteFoldersByPrefix('E2E-'); + console.log(`deleted ${deletedFolderCount} folder(s) leftover from previous test runs`); }); test('add, rename, move, search, and delete folders', async ({ page }, testInfo) => { - let foldersToCleanUp: Array = []; const onAndroid:boolean = testInfo.project.name.toLowerCase().includes('android'); await test.step('add folder (top-level)', async () => { @@ -46,7 +54,6 @@ test.describe('stormbox folder management', { page.locator('.folder-subs__name').getByText(fName, { exact: true }) ).toBeVisible() - foldersToCleanUp.push(fName); await stormbox.closeManageFoldersDialog(); }); @@ -61,7 +68,6 @@ test.describe('stormbox folder management', { page.locator('.folder-subs__name').getByText(fName, { exact: true }) ).toBeVisible(); - foldersToCleanUp.push(fName); await stormbox.closeManageFoldersDialog(); }); @@ -70,7 +76,6 @@ test.describe('stormbox folder management', { // add new top-level folder const ourTopFolder:string = `${fNamePrefix}-MULTI`; await stormbox.addFolder(ourTopFolder, 'Top Level', false, testInfo.project.name); - foldersToCleanUp.push(ourTopFolder); // now add a bunch of subfolders inside that one, all the same level const numSubFolders = 5; @@ -108,7 +113,6 @@ test.describe('stormbox folder management', { await stormbox.addFolder(ourTopFolder, 'Top Level', false, testInfo.project.name); var lvlFoldersCreated: Array = []; lvlFoldersCreated.push(ourTopFolder); - foldersToCleanUp.push(ourTopFolder); // now add a bunch of subfolders, each one inside the previous one to have multiple levels const numSubFolderLevels = 4; @@ -144,12 +148,16 @@ test.describe('stormbox folder management', { await test.step('rename folder', async () => { await stormbox.openManageFoldersDialog(testInfo.project.name); - // take the first folder that we created above and rename it - const origFName = foldersToCleanUp[0]; - await expect(page.locator('.folder-subs__name', { hasText: origFName })).toBeVisible(); + // create a new top-level folder + const origFName: string = `${fNamePrefix}-rename-me`; + await stormbox.addFolder(origFName, 'Top Level', false, testInfo.project.name); + + await expect( + page.locator('.folder-subs__name').getByText(origFName, { exact: true }) + ).toBeVisible() // rename and verify - const newFName = `RENAMED ${origFName}`; + const newFName = `${origFName}-RENAMED`; console.log(`renaming folder '${origFName}' to '${newFName}'`); await page.getByRole( 'button', @@ -168,15 +176,20 @@ test.describe('stormbox folder management', { page.locator('.folder-subs__name').getByText(newFName, { exact: true }) ).toBeVisible(); - foldersToCleanUp[0] = newFName; await stormbox.closeManageFoldersDialog(); }); await test.step('search for a folder', async () => { await stormbox.openManageFoldersDialog(testInfo.project.name); - // search for one of the folders that was created earlier - const randomElement = Math.floor(Math.random() * (foldersToCleanUp.length)); - const folderNameToFind = foldersToCleanUp[randomElement] + + // create a new top-level folder then search for it + const folderNameToFind: string = `${fNamePrefix}-find-me`; + await stormbox.addFolder(folderNameToFind, 'Top Level', false, testInfo.project.name); + + await expect( + page.locator('.folder-subs__name').getByText(folderNameToFind, { exact: true }) + ).toBeVisible(); + console.log(`searching for folder: ${folderNameToFind}`); await stormbox.manageFoldersSearchInput.fill(folderNameToFind); await expect( @@ -192,9 +205,7 @@ test.describe('stormbox folder management', { await expect( page.locator('.folder-subs__name').getByText(fName, { exact: true }) - ).toBeVisible() - - foldersToCleanUp.push(fName); + ).toBeVisible(); const starButton = page.getByRole('button', { name: `Star folder ${fName}`, @@ -260,57 +271,12 @@ test.describe('stormbox folder management', { // expand Inbox folder and verify folder is now under there await stormbox.foldersPanelExpandInboxBtn.click({ force: onAndroid }); await expect(folderLocator).toBeVisible(); - foldersToCleanUp.push(fName); }); + }); - await test.step('delete folders', async () => { - await stormbox.openManageFoldersDialog(testInfo.project.name); - // let's delete all the folders this test has created (search for all folders with fNamePrefix - // and delete them all, then search again and verify all gone) - - // top-level folders - for (const nextFolder of foldersToCleanUp) { - // find the folder - await stormbox.manageFoldersSearchInput.fill(nextFolder); - await expect( - page.locator('.folder-subs__name').getByText(nextFolder, { exact: true }) - ).toBeVisible(); - - // select it - console.log(`selecting folder for deletion: ${nextFolder}`); - const nextFolderCheckbox = page.getByRole('checkbox', { - name: `Select folder ${nextFolder}`, - exact: true, - }); - await nextFolderCheckbox.click({ force: onAndroid }); - } - - // now we have all the folders selected to delete, so delete them - await stormbox.manageFoldersDeleteSelectedFoldersBtn.click({ force: onAndroid }); - // expect 'Delete N folders' text - await expect( - stormbox.manageFoldersDialogDeleteNFoldersText - ).toHaveText(/^\s*Delete\s+[1-9]\d*\s+folders\?\s*$/); - - const text = await stormbox.manageFoldersDialogDeleteNFoldersText.textContent(); - const match = text?.match(/Delete\s+(\d+)\s+folders/); - const folderCount = Number(match?.[1]); - - // click the confirm bulk delete button - console.log(`Deleting ${folderCount} folders`); - await stormbox.manageFoldersDialogDeleteNFoldersConfirmBtn.click({ force: onAndroid }); - await page.waitForTimeout(TIMEOUT_2_SECONDS); - - // search for the folders again, they should be gone - for (const nextFolder of foldersToCleanUp) { - console.log(`searching for folder: ${nextFolder}`); - await stormbox.manageFoldersSearchInput.fill(nextFolder); - await expect( - page.locator('.folder-subs__name').getByText(nextFolder, { exact: true }) - ).not.toBeVisible(); - } - - await stormbox.closeManageFoldersDialog(); - }); + test.afterEach('delete folders', async ({ page }, testInfo) => { + // let's delete all the folders this test has created; will only run once (NOT after each test.step) + console.log('test clean-up: deleting all folders the test created'); + await stormbox.delFoldersWithGivenPrefix(fNamePrefix, testInfo.project.name); }); }); From 82d32d40400c20034009acb71845f0de1d6198ef Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 22 Sep 2026 14:18:36 -0400 Subject: [PATCH 07/11] Fix star folder test locators --- .../tests/folder-management.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts index f608918..b467b19 100644 --- a/tests/browserstack/tests/folder-management.spec.ts +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -207,17 +207,15 @@ test.describe('stormbox folder management', { page.locator('.folder-subs__name').getByText(fName, { exact: true }) ).toBeVisible(); - const starButton = page.getByRole('button', { - name: `Star folder ${fName}`, - }); + const starButton = page.locator(`[data-folder-star="${fName}"]`); // the folder is new so shouldn't be starred yet - expect(await starButton.getAttribute('aria-pressed')).toBe('false'); + await expect(starButton).toHaveAttribute('aria-pressed', 'false'); // star it and verify console.log(`starring folder: ${fName}`); await starButton.click(); - expect(await starButton.getAttribute('aria-pressed')).toBe('true'); + await expect(starButton).toHaveAttribute('aria-pressed', 'true'); // now close the manage folders dialog and then verify on folder panel our starred folder is first in the list // we look for the first folder after the 'Folders' heading so we don't get the system folders by mistate @@ -228,12 +226,10 @@ test.describe('stormbox folder management', { await stormbox.showFolderList(testInfo.project.name); } - const firstFolder = page - .getByRole('heading', { name: 'Folders', exact: true }) - .locator('xpath=..') - .locator('xpath=following-sibling::div[contains(@class, "folder-node")][1]'); - - await expect(firstFolder.locator('.folder-node__name')).toHaveText(fName); + // verify folder is now in favorites group/starred + await expect( + page.locator('.folder-node[data-tour="folder-favorites"]').filter({ hasText: fName }), + ).toBeVisible(); }); await test.step('move a folder', async () => { From d382354213eecf3e1618bfa3a3c526d87e51cc44 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 22 Sep 2026 14:28:27 -0400 Subject: [PATCH 08/11] Also ensure sign-in on desktop so we allow time for app boot if needed --- tests/browserstack/tests/folder-management.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts index b467b19..685ac04 100644 --- a/tests/browserstack/tests/folder-management.spec.ts +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -29,10 +29,8 @@ test.describe('stormbox folder management', { `Stormbox cannot run in this mobile browser. Missing: ${missing.join(', ')}.`, ); - // on mobile we need to sign in each time (desktop uses auth.desktop and saves context) - if (mobile) { - await stormbox.signInIfNeeded(testInfo.project.name); - } + // ensure app is booted and signed in (on mobile we need to sign in each time, desktop uses auth.desktop and saves context) + await stormbox.signInIfNeeded(testInfo.project.name); // The BrowserStack-only JMAP client connects directly to the deployed // stage or production Thundermail account with its app password. Remove From c0c4a8c8134f4b7d3ce95164e7885b93f02ad65d Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 22 Sep 2026 14:58:24 -0400 Subject: [PATCH 09/11] Fix manage folders dialog isVisible catch --- tests/browserstack/pages/stormbox-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 6527499..4bbc6a7 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -281,7 +281,7 @@ export class StormboxPage { async openManageFoldersDialog(projectName:string = 'desktop') { // first check if the manage folders dialog is already open, if so exit - if (await this.manageFoldersDialog.isVisible().catch(() => true)) { + if (await this.manageFoldersDialog.isVisible().catch(() => false)) { return; } From 8c3c209ba624dbf749e1e402512591205fdc28d7 Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 22 Sep 2026 15:11:24 -0400 Subject: [PATCH 10/11] Add missing projectName param --- tests/browserstack/pages/stormbox-page.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 4bbc6a7..27ccf70 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -257,9 +257,9 @@ export class StormboxPage { await this.exerciseThemeToggle(projectName); await this.exerciseMessageListControls(); await this.exerciseFolderListToggle(projectName); - await this.exerciseComposeDialog(); - await this.exerciseFolderNavigation(); - await this.exerciseManageFoldersDialog(); + await this.exerciseComposeDialog(projectName); + await this.exerciseFolderNavigation(projectName); + await this.exerciseManageFoldersDialog(projectName); await this.exerciseContactsView(projectName); await this.exerciseWelcomeModal(projectName); await this.assertExternalLinkOpensInNewTab(this.reportBugButton, BUG_REPORT_URL_PATTERN, projectName); @@ -438,9 +438,9 @@ export class StormboxPage { await this.hideFolderList(); } - private async exerciseComposeDialog() { + private async exerciseComposeDialog(projectName: string) { if (await this.showFolderListButton.isVisible().catch(() => false)) { - await this.showFolderList(); + await this.showFolderList(projectName); } await expect(this.newMessageButton).toBeVisible(); @@ -450,9 +450,9 @@ export class StormboxPage { await expect(this.composeDialog).not.toBeVisible(); } - private async exerciseFolderNavigation() { + private async exerciseFolderNavigation(projectName: string) { if (await this.showFolderListButton.isVisible().catch(() => false)) { - await this.showFolderList(); + await this.showFolderList(projectName); } await expect(this.mailboxesNav).toBeVisible(); From 75cf2d8203eeb4511fa10b4c74c2339d8224b4fe Mon Sep 17 00:00:00 2001 From: rwood-moz Date: Tue, 22 Sep 2026 15:40:15 -0400 Subject: [PATCH 11/11] Improve folder management expand inbox btn locator --- tests/browserstack/pages/stormbox-page.ts | 2 +- tests/browserstack/tests/folder-management.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/browserstack/pages/stormbox-page.ts b/tests/browserstack/pages/stormbox-page.ts index 27ccf70..d70e4a2 100644 --- a/tests/browserstack/pages/stormbox-page.ts +++ b/tests/browserstack/pages/stormbox-page.ts @@ -158,7 +158,7 @@ export class StormboxPage { this.manageFoldersNewFolderCreateBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Create' }); this.manageFoldersNewFolderCancelBtn = this.manageFoldersNewFolderDialog.getByRole('button', { name: 'Cancel' }); this.manageFoldersNewFolderNameExistsText = this.manageFoldersNewFolderDialog.getByText('A folder with that name already exists here.', { exact: true }); - this.manageFoldersExpandInboxBtn = this.manageFoldersDialog.getByRole('button', { name: 'Expand inbox' }); + this.manageFoldersExpandInboxBtn = this.manageFoldersDialog.locator('button[data-folder-toggle="Inbox"]'); this.manageFoldersRenameNameInput = this.manageFoldersDialog.getByRole('textbox', { name: 'Name' }) this.manageFoldersMoveParentDropdown = this.manageFoldersDialog.locator('[data-folder-move-select]'); this.manageFoldersMoveRenameSaveBtn = this.manageFoldersDialog.getByRole('button', { name: 'Save' }); diff --git a/tests/browserstack/tests/folder-management.spec.ts b/tests/browserstack/tests/folder-management.spec.ts index 685ac04..3d49c15 100644 --- a/tests/browserstack/tests/folder-management.spec.ts +++ b/tests/browserstack/tests/folder-management.spec.ts @@ -61,6 +61,7 @@ test.describe('stormbox folder management', { await stormbox.addFolder(fName, 'Inbox', false, testInfo.project.name); // now we need to expand the Inbox folder to see the new subfolder await stormbox.manageFoldersExpandInboxBtn.click(); + await expect(stormbox.manageFoldersExpandInboxBtn).toHaveAttribute('aria-expanded', 'true'); await expect( page.locator('.folder-subs__name').getByText(fName, { exact: true })