From 27b69f083c2ce9a3415e65747e9d34cc1c0744a6 Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Wed, 11 Oct 2023 15:27:58 +0100 Subject: [PATCH] Use a separate Stripe Connect account for each worker refs: https://github.com/TryGhost/DevOps/issues/78 This still has problems with parallel tests causing issues for each other, but is so close to a full pass test run --- .../test/e2e-browser/fixtures/ghost-test.js | 111 +++++--- .../e2e-browser/portal/member-actions.spec.js | 4 +- .../test/e2e-browser/portal/offers.spec.js | 26 +- .../test/e2e-browser/portal/upgrade.spec.js | 258 +++++++++--------- 4 files changed, 222 insertions(+), 177 deletions(-) diff --git a/ghost/core/test/e2e-browser/fixtures/ghost-test.js b/ghost/core/test/e2e-browser/fixtures/ghost-test.js index 4d58778dbf..a085552af4 100644 --- a/ghost/core/test/e2e-browser/fixtures/ghost-test.js +++ b/ghost/core/test/e2e-browser/fixtures/ghost-test.js @@ -8,9 +8,11 @@ const {allowStripe, mockMail} = require('../../utils/e2e-framework-mock-manager' const MailgunClient = require('@tryghost/mailgun-client'); const sinon = require('sinon'); const ObjectID = require('bson-objectid').default; +const Stripe = require('stripe').Stripe; +const configUtils = require('../../utils/configUtils'); const startWebhookServer = (port) => { - const command = `stripe listen --forward-to http://127.0.0.1:${port}/members/webhooks/stripe/`; + const command = `stripe listen --forward-connect-to http://127.0.0.1:${port}/members/webhooks/stripe/`; return spawn(command.split(' ')[0], command.split(' ').slice(1)); }; @@ -20,14 +22,43 @@ const getWebhookSecret = async () => { return webhookSecret.toString().trim(); }; -const generateStripeIntegrationToken = async () => { +const getStripeAccountId = async (workerIndex) => { + let accountId; + const accountEmail = `test${workerIndex}@example.com`; + + const secretKey = process.env.STRIPE_SECRET_KEY; + const stripe = new Stripe(secretKey, { + apiVersion: '2020-08-27' + }); + const accounts = await stripe.accounts.list(); + if (accounts.data.length > 0) { + const account = accounts.data.find(acc => acc.email === accountEmail); + if (account) { + await stripe.accounts.del(account.id); + } + } + if (!accountId) { + const account = await stripe.accounts.create({ + type: 'standard', + email: accountEmail, + business_type: 'company', + company: { + name: `Test Company ${workerIndex}` + } + }); + accountId = account.id; + } + + return accountId; +}; + +const generateStripeIntegrationToken = async (accountId) => { if (!('STRIPE_PUBLISHABLE_KEY' in process.env) || !('STRIPE_SECRET_KEY' in process.env)) { throw new Error('Missing STRIPE_PUBLISHABLE_KEY or STRIPE_SECRET_KEY environment variables'); } const publishableKey = process.env.STRIPE_PUBLISHABLE_KEY; const secretKey = process.env.STRIPE_SECRET_KEY; - const accountId = process.env.STRIPE_ACCOUNT_ID ?? JSON.parse((await promisify(exec)('stripe get account')).stdout).id; return Buffer.from(JSON.stringify({ a: secretKey, @@ -39,7 +70,6 @@ const generateStripeIntegrationToken = async () => { // Global promises for webhook secret / Stripe integration token const webhookSecretPromise = getWebhookSecret(); -const stripeIntegrationTokenPromise = generateStripeIntegrationToken(); module.exports = base.test.extend({ baseURL: async ({port, baseURL}, use) => { @@ -64,31 +94,35 @@ module.exports = base.test.extend({ const currentDate = new Date(); const formattedDate = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${String(currentDate.getHours()).padStart(2, '0')}-${String(currentDate.getMinutes()).padStart(2, '0')}-${String(currentDate.getSeconds()).padStart(2, '0')}`; process.env.database__connection__filename = `/tmp/ghost-playwright.${workerInfo.workerIndex}.${formattedDate}.db`; + configUtils.set('database:connection:filename', process.env.database__connection__filename); + configUtils.set('server:port', port); + configUtils.set('url', `http://127.0.0.1:${port}`); + + const stripeAccountId = await getStripeAccountId(workerInfo.workerIndex); + const stripeIntegrationToken = await generateStripeIntegrationToken(stripeAccountId); + + const WebhookManager = require('../../../../stripe/lib/WebhookManager'); + const originalParseWebhook = WebhookManager.prototype.parseWebhook; const sandbox = sinon.createSandbox(); - const originalConfigGet = config.get.bind(config); - sandbox.stub(config, 'get').callsFake((key) => { - if (key === 'database:connection:filename') { - return process.env.database__connection__filename; + sandbox.stub(WebhookManager.prototype, 'parseWebhook').callsFake(function (body, signature) { + const parsedBody = JSON.parse(body); + if (!('account' in parsedBody)) { + throw new Error('Webhook without account'); + } else if (parsedBody.account !== stripeAccountId) { + throw new Error('Webhook for wrong account'); + } else { + return originalParseWebhook.call(this, body, signature); } - if (key === 'database') { - return { - client: 'sqlite3', - connection: { - filename: process.env.database__connection__filename - }, - useNullAsDefault: true, - debug: false - }; - } - if (key === 'server') { - return { - port - }; - } - if (key === 'url') { - return `http://127.0.0.1:${port}`; - } - return originalConfigGet(key); + }); + + const StripeAPI = require('../../../../stripe/lib/StripeAPI'); + const originalStripeConfigure = StripeAPI.prototype.configure; + sandbox.stub(StripeAPI.prototype, 'configure').callsFake(function (stripeConfig) { + originalStripeConfigure.call(this, stripeConfig); + this._stripe = new Stripe(stripeConfig.secretKey, { + apiVersion: '2020-08-27', + stripeAccount: stripeAccountId + }); }); const stripeServer = startWebhookServer(port); @@ -124,7 +158,7 @@ module.exports = base.test.extend({ }); await setupGhost(page); - await setupStripe(page, await stripeIntegrationTokenPromise); + await setupStripe(page, stripeIntegrationToken); await setupMailgun(page); await enableLabs(page); const state = await page.context().storageState(); @@ -132,15 +166,16 @@ module.exports = base.test.extend({ await page.close(); // Use the server in the tests. - await use({ - server, - state - }); - - // Cleanup. - const {stopGhost} = require('../../utils/e2e-utils'); - await stopGhost(); - stripeServer.kill(); - sandbox.restore(); + try { + await use({ + server, + state + }); + } finally { + const {stopGhost} = require('../../utils/e2e-utils'); + await stopGhost(); + stripeServer.kill(); + sandbox.restore(); + } }, {scope: 'worker', auto: true}] }); diff --git a/ghost/core/test/e2e-browser/portal/member-actions.spec.js b/ghost/core/test/e2e-browser/portal/member-actions.spec.js index 08a5541174..93b03b2c6f 100644 --- a/ghost/core/test/e2e-browser/portal/member-actions.spec.js +++ b/ghost/core/test/e2e-browser/portal/member-actions.spec.js @@ -23,7 +23,9 @@ const addNewsletter = async (page) => { test.describe('Portal', () => { test.describe('Member actions', () => { - test.describe.configure({retries: 1}); + // Use serial mode as the order of tests matters, we create newsletters during the tests + // TODO: Use a `before` block to create all the requisite newsletters before the tests run + test.describe.configure({retries: 1, mode: 'serial'}); test('can log out', async ({page}) => { // create a new free member diff --git a/ghost/core/test/e2e-browser/portal/offers.spec.js b/ghost/core/test/e2e-browser/portal/offers.spec.js index 7b59df3159..eaf87dbdaa 100644 --- a/ghost/core/test/e2e-browser/portal/offers.spec.js +++ b/ghost/core/test/e2e-browser/portal/offers.spec.js @@ -10,7 +10,7 @@ test.describe('Portal', () => { await deleteAllMembers(page); // add a new tier for offers - const tierName = 'Portal Tier'; + const tierName = 'Trial Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -44,7 +44,7 @@ test.describe('Portal', () => { // fill member details and click start trial await portalFrame.locator('[data-test-input="input-name"]').fill('Testy McTesterson'); - await portalFrame.locator('[data-test-input="input-email"]').fill('testy@example.com'); + await portalFrame.locator('[data-test-input="input-email"]').fill('testy+trial@example.com'); await portalFrame.getByRole('button', {name: 'Start 14-day free trial'}).click(); // handle newsletter selection page if it opens and click continue @@ -67,7 +67,7 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy+trial@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); // Ensure the offer redemption count was bumped @@ -82,7 +82,7 @@ test.describe('Portal', () => { await deleteAllMembers(page); // add new tier - const tierName = 'Portal Tier'; + const tierName = 'One-off Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -115,7 +115,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy@example.com'); + await portalFrame.locator('#input-email').fill('testy+oneoff@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check if newsletter selection screen is shown and continue @@ -137,7 +137,7 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy+oneoff@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); @@ -147,7 +147,7 @@ test.describe('Portal', () => { await deleteAllMembers(page); // add new tier - const tierName = 'Portal Tier'; + const tierName = 'Multiple-month Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -184,7 +184,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy@example.com'); + await portalFrame.locator('#input-email').fill('testy+multi@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check newsletter selection if shown and continue @@ -205,7 +205,7 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy+multi@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); @@ -215,7 +215,7 @@ test.describe('Portal', () => { await deleteAllMembers(page); // add tier - const tierName = 'Portal Tier'; + const tierName = 'Forever Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, @@ -251,7 +251,7 @@ test.describe('Portal', () => { // fill member details and continue await portalFrame.locator('#input-name').fill('Testy McTesterson'); - await portalFrame.locator('#input-email').fill('testy@example.com'); + await portalFrame.locator('#input-email').fill('testy+forever@example.com'); await portalFrame.getByRole('button', {name: 'Continue'}).click(); // check if newsletter selection page is shown and continue @@ -270,7 +270,7 @@ test.describe('Portal', () => { await page.locator('.gh-nav a[href="#/members/"]').click(); // 1 member, should be Testy, on Portal Tier - await expect(page.getByRole('link', {name: 'Testy McTesterson testy@example.com'}), 'Should have 1 paid member').toBeVisible(); + await expect(page.getByRole('link', {name: 'Testy McTesterson testy+forever@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(page.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); }); @@ -278,7 +278,7 @@ test.describe('Portal', () => { await page.goto('/ghost'); // Create a new tier to attach offer to - const tierName = 'Portal Tier'; + const tierName = 'Archive Test Tier'; await createTier(page, { name: tierName, monthlyPrice: 6, diff --git a/ghost/core/test/e2e-browser/portal/upgrade.spec.js b/ghost/core/test/e2e-browser/portal/upgrade.spec.js index 9e5ba7303e..a2cdb12ad2 100644 --- a/ghost/core/test/e2e-browser/portal/upgrade.spec.js +++ b/ghost/core/test/e2e-browser/portal/upgrade.spec.js @@ -1,156 +1,164 @@ const {expect} = require('@playwright/test'); const test = require('../fixtures/ghost-test'); -const {completeStripeSubscription, createMember, impersonateMember} = require('../utils'); +const {completeStripeSubscription, createMember, createTier, impersonateMember} = require('../utils'); + +const tierName = 'Upgrade Tests'; test.describe('Portal', () => { - test.describe('Upgrade: Comped Member', () => { - test('allows comped member to upgrade to paid tier', async ({page}) => { - const tierName = 'The Local Test'; - - // create a new member - await page.goto('/ghost'); - await createMember(page, { - name: 'Testy McTest', - email: 'testy+upgradecompedportal@example.com', - note: 'Testy McTest is a test member' - }); - - //get the url of the current member on admin - const memberUrl = page.url(); - - // Give member comped subscription - await page.locator('[data-test-button="add-complimentary"]').click(); - await page.locator('[data-test-button="save-comp-tier"]').first().click({ - delay: 500 - }); - - await page.waitForLoadState('networkidle'); - await impersonateMember(page); - - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - //await page.pause(); - - // open portal, go to plans and click continue to select the first plan(yearly) - await portalTriggerButton.click(); - await portalFrame.getByRole('button', {name: 'Change'}).click(); - - // select the tier for checkout (yearly) - await choseTierByName(portalFrame, tierName); - - // complete stripe checkout - await completeStripeSubscription(page); - - // open portal and check that member has been upgraded to paid tier - await portalTriggerButton.click(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); - await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible(); - await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); - - // check that member has been upgraded in admin and a tier exists for them - await page.goto(memberUrl); - await expect(page.locator('[data-test-tier]').first()).toBeVisible(); - }); - }); - - test.describe('Upgrade: Single Tier', () => { - // Because memberUrl is set during first test, we need to run these tests in series + test.describe('Upgrades', () => { + // Tier created in first test used in subsequent tests test.describe.configure({mode: 'serial'}); - let memberUrl; - const tierName = 'The Local Test'; - const member = { - name: 'Testy McTest', - email: 'testy+upgradeportal@example.com', - note: 'Testy McTest is a test member' - }; + test.describe('Upgrade: Comped Member', () => { + test('allows comped member to upgrade to paid tier', async ({page}) => { + // create a new member + await page.goto('/ghost'); + await createTier(page, { + name: tierName, + monthlyPrice: 5, + yearlyPrice: 50 + }); + await createMember(page, { + name: 'Testy McTest', + email: 'testy+upgradecompedportal@example.com', + note: 'Testy McTest is a test member' + }); - test('allows free member upgrade to paid tier', async ({page}) => { - await page.goto('/ghost'); + //get the url of the current member on admin + const memberUrl = page.url(); - // create a new free member - await page.goto('/ghost'); - await createMember(page, member); + // Give member comped subscription + await page.locator('[data-test-button="add-complimentary"]').click(); + await page.locator('[data-test-button="save-comp-tier"]').first().click({ + delay: 500 + }); - //store the url of the member detail page - memberUrl = page.url(); + await page.waitForLoadState('networkidle'); + await impersonateMember(page); - // impersonate the member on frontend - await impersonateMember(page); + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + // open portal, go to plans and click continue to select the first plan(yearly) + await portalTriggerButton.click(); + await portalFrame.getByRole('button', {name: 'Change'}).click(); - // open portal, go to plans and click continue to select the first plan(yearly) - await portalTriggerButton.click(); - // verify the member we created is logged in - await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible(); - // view plans button only shows for free member - await portalFrame.getByRole('button', {name: 'View plans'}).click(); + // select the tier for checkout (yearly) + await choseTierByName(portalFrame, tierName); - // select the tier for checkout (yearly) - await choseTierByName(portalFrame, tierName); + // complete stripe checkout + await completeStripeSubscription(page); - // complete stripe checkout - await completeStripeSubscription(page); + // open portal and check that member has been upgraded to paid tier + await portalTriggerButton.click(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + await expect(portalFrame.getByRole('heading', {name: 'Billing info'})).toBeVisible(); + await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); - // open portal and check that member has been upgraded to paid tier - await portalTriggerButton.click(); - // verify member's tier, price and card details - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); - await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); - - // verify member's tier on member detail page in admin - await page.goto(memberUrl); - const tierCard = await page.locator('[data-test-tier]').first(); - const tierText = await tierCard.locator('[data-test-text="tier-name"]'); - await expect(tierCard).toBeVisible(); - await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName)); + // check that member has been upgraded in admin and a tier exists for them + await page.goto(memberUrl); + await expect(page.locator('[data-test-tier]').first()).toBeVisible(); + }); }); - test('allows member to switch plans', async ({page}) => { - // go to member detail page in admin - await page.goto(memberUrl); + test.describe('Upgrade: Single Tier', () => { + // Because memberUrl is set during first test, we need to run these tests in series + test.describe.configure({mode: 'serial'}); - // impersonate the member on frontend - await impersonateMember(page); + let memberUrl; + const member = { + name: 'Testy McTest', + email: 'testy+upgradeportal@example.com', + note: 'Testy McTest is a test member' + }; - const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); - const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + test('allows free member upgrade to paid tier', async ({page}) => { + await page.goto('/ghost'); - // open portal - await portalTriggerButton.click(); + // create a new free member + await page.goto('/ghost'); + await createMember(page, member); - // test member can switch to monthly plan from yearly - await portalFrame.locator('[data-test-button="change-plan"]').click(); + //store the url of the member detail page + memberUrl = page.url(); - await portalFrame.locator('[data-test-button="switch-monthly"]').click(); + // impersonate the member on frontend + await impersonateMember(page); - // select the monthly plan - await choseTierByName(portalFrame, tierName); + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); - // confirm the switch - await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); - // verify member has switched to monthly plan - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$5.00/month')).toBeVisible(); + // open portal, go to plans and click continue to select the first plan(yearly) + await portalTriggerButton.click(); + // verify the member we created is logged in + await expect(portalFrame.getByText('testy+upgradeportal@example.com')).toBeVisible(); + // view plans button only shows for free member + await portalFrame.getByRole('button', {name: 'View plans'}).click(); - // test member can switch back to yearly - await portalFrame.locator('[data-test-button="change-plan"]').click(); - await portalFrame.locator('[data-test-button="switch-yearly"]').click(); - // select the monthly plan - await choseTierByName(portalFrame, tierName); - // confirm the switch - await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); - // verify member has switched to yearly plan, timeout added to allow for delays - await expect(portalFrame.getByText(tierName)).toBeVisible(); - await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + // select the tier for checkout (yearly) + await choseTierByName(portalFrame, tierName); + + // complete stripe checkout + await completeStripeSubscription(page); + + // open portal and check that member has been upgraded to paid tier + await portalTriggerButton.click(); + // verify member's tier, price and card details + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + await expect(portalFrame.getByText('**** **** **** 4242')).toBeVisible(); + + // verify member's tier on member detail page in admin + await page.goto(memberUrl); + const tierCard = await page.locator('[data-test-tier]').first(); + const tierText = await tierCard.locator('[data-test-text="tier-name"]'); + await expect(tierCard).toBeVisible(); + await expect(tierText, 'Where is tier text').toHaveText(new RegExp(tierName)); + }); + + test('allows member to switch plans', async ({page}) => { + // go to member detail page in admin + await page.goto(memberUrl); + + // impersonate the member on frontend + await impersonateMember(page); + + const portalTriggerButton = page.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); + const portalFrame = page.frameLocator('[data-testid="portal-popup-frame"]'); + + // open portal + await portalTriggerButton.click(); + + // test member can switch to monthly plan from yearly + await portalFrame.locator('[data-test-button="change-plan"]').click(); + + await portalFrame.locator('[data-test-button="switch-monthly"]').click(); + + // select the monthly plan + await choseTierByName(portalFrame, tierName); + + // confirm the switch + await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); + // verify member has switched to monthly plan + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$5.00/month')).toBeVisible(); + + // test member can switch back to yearly + await portalFrame.locator('[data-test-button="change-plan"]').click(); + await portalFrame.locator('[data-test-button="switch-yearly"]').click(); + // select the monthly plan + await choseTierByName(portalFrame, tierName); + // confirm the switch + await portalFrame.locator('[data-test-button="confirm-action"]').first().click(); + // verify member has switched to yearly plan, timeout added to allow for delays + await expect(portalFrame.getByText(tierName)).toBeVisible(); + await expect(portalFrame.getByText('$50.00/year')).toBeVisible(); + }); }); }); }); -async function choseTierByName(portalFrame, tierName) { - const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tierName}).first(); +async function choseTierByName(portalFrame, tier) { + const portalTierCard = await portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tier}).first(); await portalTierCard.locator('[data-test-button="select-tier"]').click(); }