Enhance E2E Tests and Configuration (#358)

* Enhance E2E Tests and Configuration

- Updated `.gitignore` to exclude `.auth/` directory for cleaner test environment.
- Added `test:fast` script in `package.json` for faster Playwright test execution.
- Configured Playwright to include a setup project for initializing tests.
- Introduced `AUTH_STATES` utility for managing authentication states in tests.
- Created `auth.setup.ts` for user authentication tests, ensuring proper login flows.
- Refactored various test files to utilize the new authentication state management, improving test reliability and maintainability.
- Adjusted team and user billing tests to streamline setup and enhance clarity.
- Refactored some dialogs
This commit is contained in:
Giancarlo Buomprisco
2025-09-21 12:28:42 +08:00
committed by GitHub
parent f157cc7f3e
commit 02e2502dcc
27 changed files with 661 additions and 407 deletions

1
apps/e2e/.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
.auth

105
apps/e2e/AGENTS.md Normal file
View File

@@ -0,0 +1,105 @@
## End-to-End Testing with Playwright
### Page Object Pattern (Required)
Always use Page Objects for test organization and reusability:
```typescript
// Example: auth.po.ts
export class AuthPageObject {
constructor(private readonly page: Page) {}
async signIn(params: { email: string; password: string }) {
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
await this.page.click('button[type="submit"]');
}
async signOut() {
await this.page.click('[data-test="account-dropdown-trigger"]');
await this.page.click('[data-test="account-dropdown-sign-out"]');
}
}
```
### Reliability Patterns
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
```typescript
// ✅ CORRECT - Reliable email/OTP operations
await expect(async () => {
const otpCode = await this.getOtpCodeFromEmail(email);
expect(otpCode).not.toBeNull();
await this.enterOtpCode(otpCode);
}).toPass();
// ✅ CORRECT - Network requests with validation
await expect(async () => {
const response = await this.page.waitForResponse(resp =>
resp.url().includes('auth/v1/user')
);
expect(response.status()).toBe(200);
}).toPass();
// ✅ CORRECT - Complex operations with custom intervals
await expect(async () => {
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
}).toPass({
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
});
```
### Test Data Management
**Email Testing**: Use `createRandomEmail()` for unique test emails:
```typescript
createRandomEmail() {
const value = Math.random() * 10000000000000;
return `${value.toFixed(0)}@makerkit.dev`;
}
```
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
```typescript
await auth.bootstrapUser({
email: 'test@example.com',
password: 'testingpassword',
name: 'Test User'
});
```
This method creates a user with an API call.
To sign in:
```tsx
await auth.loginAsUser({
email: 'test@example.com',
password: 'testingpassword',
});
```
### Test Selectors
**Always use `data-test` attributes** for reliable element selection:
```typescript
// ✅ CORRECT - Use data-test attributes
await this.page.click('[data-test="submit-button"]');
await this.page.fill('[data-test="email-input"]', email);
// ✅ OR
await this.page.getByTestId('submit-button').click();
// ❌ AVOID - Fragile selectors
await this.page.click('.btn-primary');
await this.page.click('button:nth-child(2)');
```
### Test Organization
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
- **Page Objects**: `*.po.ts` files for reusable page interactions
- **Setup files**: `auth.setup.ts` for global test setup
- **Utility classes**: `/tests/utils/` for shared functionality

105
apps/e2e/CLAUDE.md Normal file
View File

@@ -0,0 +1,105 @@
## End-to-End Testing with Playwright
### Page Object Pattern (Required)
Always use Page Objects for test organization and reusability:
```typescript
// Example: auth.po.ts
export class AuthPageObject {
constructor(private readonly page: Page) {}
async signIn(params: { email: string; password: string }) {
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
await this.page.click('button[type="submit"]');
}
async signOut() {
await this.page.click('[data-test="account-dropdown-trigger"]');
await this.page.click('[data-test="account-dropdown-sign-out"]');
}
}
```
### Reliability Patterns
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
```typescript
// ✅ CORRECT - Reliable email/OTP operations
await expect(async () => {
const otpCode = await this.getOtpCodeFromEmail(email);
expect(otpCode).not.toBeNull();
await this.enterOtpCode(otpCode);
}).toPass();
// ✅ CORRECT - Network requests with validation
await expect(async () => {
const response = await this.page.waitForResponse(resp =>
resp.url().includes('auth/v1/user')
);
expect(response.status()).toBe(200);
}).toPass();
// ✅ CORRECT - Complex operations with custom intervals
await expect(async () => {
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
}).toPass({
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
});
```
### Test Data Management
**Email Testing**: Use `createRandomEmail()` for unique test emails:
```typescript
createRandomEmail() {
const value = Math.random() * 10000000000000;
return `${value.toFixed(0)}@makerkit.dev`;
}
```
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
```typescript
await auth.bootstrapUser({
email: 'test@example.com',
password: 'testingpassword',
name: 'Test User'
});
```
This method creates a user with an API call.
To sign in:
```tsx
await auth.loginAsUser({
email: 'test@example.com',
password: 'testingpassword',
});
```
### Test Selectors
**Always use `data-test` attributes** for reliable element selection:
```typescript
// ✅ CORRECT - Use data-test attributes
await this.page.click('[data-test="submit-button"]');
await this.page.fill('[data-test="email-input"]', email);
// ✅ OR
await this.page.getByTestId('submit-button').click();
// ❌ AVOID - Fragile selectors
await this.page.click('.btn-primary');
await this.page.click('button:nth-child(2)');
```
### Test Organization
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
- **Page Objects**: `*.po.ts` files for reusable page interactions
- **Setup files**: `auth.setup.ts` for global test setup
- **Utility classes**: `/tests/utils/` for shared functionality

View File

@@ -1,18 +1,18 @@
{ {
"name": "web-e2e", "name": "web-e2e",
"version": "1.0.0", "version": "1.0.0",
"description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"report": "playwright show-report", "report": "playwright show-report",
"test": "playwright test --max-failures=1", "test": "playwright test --max-failures=1",
"test:fast": "playwright test --max-failures=1 --workers=16",
"test:setup": "playwright test tests/auth.setup.ts",
"test:ui": "playwright test --ui" "test:ui": "playwright test --ui"
}, },
"keywords": [], "author": "Makerkit",
"author": "",
"license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
"@supabase/supabase-js": "2.57.4",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"dotenv": "17.2.2", "dotenv": "17.2.2",
"node-html-parser": "^7.0.1", "node-html-parser": "^7.0.1",

View File

@@ -7,9 +7,10 @@ dotenvConfig({ path: '.env.local' });
/** /**
* Number of workers to use in CI. Tweak based on your CI provider's resources. * Number of workers to use in CI. Tweak based on your CI provider's resources.
*/ */
const CI_WORKERS = 3; const CI_WORKERS = 2;
const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true'; const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true';
const enableTeamAccountTests = const enableTeamAccountTests =
(process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true'; (process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true';
@@ -50,7 +51,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: 3, retries: 2,
/* Limit parallel tests on CI. */ /* Limit parallel tests on CI. */
workers: process.env.CI ? CI_WORKERS : undefined, workers: process.env.CI ? CI_WORKERS : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
@@ -68,6 +69,7 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
navigationTimeout: 15 * 1000, navigationTimeout: 15 * 1000,
testIdAttribute: 'data-test',
}, },
// test timeout set to 2 minutes // test timeout set to 2 minutes
timeout: 120 * 1000, timeout: 120 * 1000,
@@ -77,9 +79,11 @@ export default defineConfig({
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ {
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
}, },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
// { // {

View File

@@ -14,10 +14,6 @@ export class AccountPageObject {
this.otp = new OtpPo(page); this.otp = new OtpPo(page);
} }
async setup() {
return this.auth.signUpFlow('/home/settings');
}
async updateName(name: string) { async updateName(name: string) {
await this.page.fill('[data-test="update-account-name-form"] input', name); await this.page.fill('[data-test="update-account-name-form"] input', name);
await this.page.click('[data-test="update-account-name-form"] button'); await this.page.click('[data-test="update-account-name-form"] button');

View File

@@ -1,20 +1,33 @@
import { Page, expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { AccountPageObject } from './account.po'; import { AccountPageObject } from './account.po';
import {AuthPageObject} from "../authentication/auth.po";
test.describe('Account Settings', () => { test.describe('Account Settings', () => {
let page: Page;
let account: AccountPageObject; let account: AccountPageObject;
let email: string;
test.beforeAll(async ({ browser }) => { test.beforeEach(async ({ page }) => {
page = await browser.newPage(); const auth = new AuthPageObject(page);
account = new AccountPageObject(page);
await account.setup(); email = auth.createRandomEmail();
auth.bootstrapUser({
email,
password: 'testingpassword',
name: 'Test User',
}); });
test('user can update their profile name', async () => { account = new AccountPageObject(page);
await auth.loginAsUser({
email,
password: 'testingpassword',
next: '/home/settings',
});
});
test('user can update their profile name', async ({ page }) => {
const name = 'John Doe'; const name = 'John Doe';
const request = account.updateName(name); const request = account.updateName(name);
@@ -28,13 +41,13 @@ test.describe('Account Settings', () => {
await expect(account.getProfileName()).toHaveText(name); await expect(account.getProfileName()).toHaveText(name);
}); });
test('user can update their email', async () => { test('user can update their email', async ({ page }) => {
const email = account.auth.createRandomEmail(); const email = account.auth.createRandomEmail();
await account.updateEmail(email); await account.updateEmail(email);
}); });
test('user can update their password', async () => { test('user can update their password', async ({ page }) => {
const password = (Math.random() * 100000).toString(); const password = (Math.random() * 100000).toString();
const request = account.updatePassword(password); const request = account.updatePassword(password);
@@ -53,10 +66,19 @@ test.describe('Account Settings', () => {
test.describe('Account Deletion', () => { test.describe('Account Deletion', () => {
test('user can delete their own account', async ({ page }) => { test('user can delete their own account', async ({ page }) => {
const account = new AccountPageObject(page); // Create a fresh user for this test since we'll be deleting it
const auth = new AuthPageObject(page); const auth = new AuthPageObject(page);
const account = new AccountPageObject(page);
const { email } = await account.setup(); const email = auth.createRandomEmail();
await auth.bootstrapUser({
email,
password: 'testingpassword',
name: 'Test User',
});
await auth.loginAsUser({ email, next: '/home/settings' });
await account.deleteAccount(email); await account.deleteAccount(email);
@@ -70,6 +92,8 @@ test.describe('Account Deletion', () => {
password: 'testingpassword', password: 'testingpassword',
}); });
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible(); await expect(
page.locator('[data-test="auth-error-message"]'),
).toBeVisible();
}); });
}); });

View File

@@ -1,40 +1,23 @@
import { Page, expect, selectors, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po'; import { AuthPageObject } from '../authentication/auth.po';
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
import { AUTH_STATES } from '../utils/auth-state';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
test.describe('Admin Auth flow without MFA', () => { test.describe('Admin Auth flow without MFA', () => {
AuthPageObject.setupSession(AUTH_STATES.OWNER_USER);
test('will return a 404 for non-admin users', async ({ page }) => { test('will return a 404 for non-admin users', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'owner@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home');
await page.goto('/admin'); await page.goto('/admin');
expect(page.url()).toContain('/404'); expect(page.url()).toContain('/404');
}); });
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'test@makerkit.dev',
password: 'testingpassword',
}); });
await page.waitForURL('/home'); test.describe('Admin Auth flow with Super Admin but without MFA', () => {
AuthPageObject.setupSession(AUTH_STATES.TEST_USER);
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
await page.goto('/admin'); await page.goto('/admin');
expect(page.url()).toContain('/404'); expect(page.url()).toContain('/404');
@@ -42,12 +25,13 @@ test.describe('Admin Auth flow without MFA', () => {
}); });
test.describe('Admin', () => { test.describe('Admin', () => {
// must be serial because OTP verification is not working in parallel
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test.describe('Admin Dashboard', () => { test.describe('Admin Dashboard', () => {
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
test('displays all stat cards', async ({ page }) => { test('displays all stat cards', async ({ page }) => {
await goToAdmin(page); await page.goto('/admin');
// Check all stat cards are present // Check all stat cards are present
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
@@ -73,19 +57,14 @@ test.describe('Admin', () => {
}); });
test.describe('Personal Account Management', () => { test.describe('Personal Account Management', () => {
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
let testUserEmail: string; let testUserEmail: string;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test');
// Create a new test user before each test // Create a new test user before each test
testUserEmail = await createUser(page); testUserEmail = await createUser(page);
await goToAdmin(page);
// Navigate to the newly created user's account page
// Note: We need to get the user's ID from the email - this might need adjustment
// based on your URL structure
await page.goto(`/admin/accounts`); await page.goto(`/admin/accounts`);
// use the email as the filter text // use the email as the filter text
@@ -99,6 +78,7 @@ test.describe('Admin', () => {
await expect(page.getByText('Personal Account')).toBeVisible(); await expect(page.getByText('Personal Account')).toBeVisible();
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible(); await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible(); await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
await expect( await expect(
page.getByTestId('admin-delete-account-button'), page.getByTestId('admin-delete-account-button'),
).toBeVisible(); ).toBeVisible();
@@ -106,6 +86,7 @@ test.describe('Admin', () => {
test('ban user flow', async ({ page }) => { test('ban user flow', async ({ page }) => {
await page.getByTestId('admin-ban-account-button').click(); await page.getByTestId('admin-ban-account-button').click();
await expect( await expect(
page.getByRole('heading', { name: 'Ban User' }), page.getByRole('heading', { name: 'Ban User' }),
).toBeVisible(); ).toBeVisible();
@@ -189,29 +170,14 @@ test.describe('Admin', () => {
const auth = new AuthPageObject(page); const auth = new AuthPageObject(page);
await auth.signIn({ await auth.loginAsUser({
email: testUserEmail, email: testUserEmail,
password: 'testingpassword',
}); });
await page.waitForURL('/home');
});
test('impersonate user flow', async ({ page }) => {
await page.getByTestId('admin-impersonate-button').click();
await expect(
page.getByRole('heading', { name: 'Impersonate User' }),
).toBeVisible();
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Impersonate User' }).click();
// Should redirect to home and be logged in as the user
await page.waitForURL('/home');
}); });
test('delete user flow', async ({ page }) => { test('delete user flow', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.getByTestId('admin-delete-account-button').click(); await page.getByTestId('admin-delete-account-button').click();
await expect( await expect(
@@ -236,13 +202,10 @@ test.describe('Admin', () => {
await page.waitForURL('/admin/accounts'); await page.waitForURL('/admin/accounts');
// Log out // Log out
await page.context().clearCookies(); await auth.signOut();
await page.waitForURL('/'); await page.waitForURL('/');
// Verify user can't log in await auth.goToSignIn();
await page.goto('/auth/sign-in');
const auth = new AuthPageObject(page);
await auth.signIn({ await auth.signIn({
email: testUserEmail, email: testUserEmail,
@@ -256,7 +219,36 @@ test.describe('Admin', () => {
}); });
}); });
test.describe('Impersonation', () => {
test('can sign in as a user', async ({ page }) => {
const auth = new AuthPageObject(page);
await auth.loginAsSuperAdmin({});
const filterText = await createUser(page);
await page.goto(`/admin/accounts`);
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
await page.getByTestId('admin-impersonate-button').click();
await expect(
page.getByRole('heading', { name: 'Impersonate User' }),
).toBeVisible();
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Impersonate User' }).click();
// Should redirect to home and be logged in as the user
await page.waitForURL('/home');
});
});
});
test.describe('Team Account Management', () => { test.describe('Team Account Management', () => {
test.describe.configure({ mode: 'serial' });
test.skip( test.skip(
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true', process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
'Team account tests are disabled', 'Team account tests are disabled',
@@ -267,13 +259,15 @@ test.describe('Admin', () => {
let slug: string; let slug: string;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test'); const auth = new AuthPageObject(page);
// Create a new test user and team account // Create a new test user and team account
testUserEmail = await createUser(page, { testUserEmail = await createUser(page);
afterSignIn: async () => {
teamName = `test-${Math.random().toString(36).substring(2, 15)}`; teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
await auth.loginAsUser({ email: testUserEmail });
const teamAccountPo = new TeamAccountsPageObject(page); const teamAccountPo = new TeamAccountsPageObject(page);
const teamSlug = teamName.toLowerCase().replace(/ /g, '-'); const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
@@ -283,10 +277,13 @@ test.describe('Admin', () => {
teamName, teamName,
slug, slug,
}); });
},
});
await goToAdmin(page); await page.waitForTimeout(250);
await auth.signOut();
await page.waitForURL('/');
await auth.loginAsSuperAdmin({});
await page.goto(`/admin/accounts`); await page.goto(`/admin/accounts`);
@@ -294,15 +291,11 @@ test.describe('Admin', () => {
await selectAccount(page, teamName); await selectAccount(page, teamName);
}); });
test('displays team account details', async ({ page }) => {
await expect(page.getByText('Team Account')).toBeVisible();
await expect(
page.getByTestId('admin-delete-account-button'),
).toBeVisible();
});
test('delete team account flow', async ({ page }) => { test('delete team account flow', async ({ page }) => {
await expect(page.getByText('Team Account')).toBeVisible();
await page.getByTestId('admin-delete-account-button').click(); await page.getByTestId('admin-delete-account-button').click();
await expect( await expect(
page.getByRole('heading', { name: 'Delete Account' }), page.getByRole('heading', { name: 'Delete Account' }),
).toBeVisible(); ).toBeVisible();
@@ -322,64 +315,20 @@ test.describe('Admin', () => {
await expect(page).toHaveURL('/admin/accounts'); await expect(page).toHaveURL('/admin/accounts');
}); });
}); });
});
async function goToAdmin(page: Page) { async function createUser(page: Page) {
const auth = new AuthPageObject(page); const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'super-admin@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/auth/verify');
await page.waitForTimeout(250);
await expect(async () => {
await auth.submitMFAVerification(MFA_KEY);
await page.waitForURL('/home');
}).toPass({
intervals: [
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
40_000, 45_000, 50_000,
],
});
await page.goto('/admin');
}
async function createUser(
page: Page,
params: {
afterSignIn?: () => Promise<void>;
} = {},
) {
const auth = new AuthPageObject(page);
const password = 'testingpassword'; const password = 'testingpassword';
const email = auth.createRandomEmail(); const email = auth.createRandomEmail();
// sign up // create user using bootstrap method
await page.goto('/auth/sign-up'); await auth.bootstrapUser({
await auth.signUp({
email, email,
password, password,
repeatPassword: password, name: 'Test User',
}); });
// confirm email
await auth.visitConfirmEmailLink(email);
if (params.afterSignIn) {
await params.afterSignIn();
}
// sign out
await auth.signOut();
await page.waitForURL('/');
// return the email // return the email
return email; return email;
} }
@@ -404,6 +353,6 @@ async function selectAccount(page: Page, email: string) {
await link.click(); await link.click();
await page.waitForLoadState('networkidle'); await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
}).toPass(); }).toPass();
} }

View File

@@ -0,0 +1,37 @@
import { test } from '@playwright/test';
import { join } from 'node:path';
import { cwd } from 'node:process';
import { AuthPageObject } from './authentication/auth.po';
const testAuthFile = join(cwd(), '.auth/test@makerkit.dev.json');
const ownerAuthFile = join(cwd(), '.auth/owner@makerkit.dev.json');
const superAdminAuthFile = join(cwd(), '.auth/super-admin@makerkit.dev.json');
test('authenticate as test user', async ({ page }) => {
const auth = new AuthPageObject(page);
await auth.loginAsUser({
email: 'test@makerkit.dev',
});
await page.context().storageState({ path: testAuthFile });
});
test('authenticate as owner user', async ({ page }) => {
const auth = new AuthPageObject(page);
await auth.loginAsUser({
email: 'owner@makerkit.dev',
});
await page.context().storageState({ path: ownerAuthFile });
});
test('authenticate as super-admin user', async ({ page }) => {
const auth = new AuthPageObject(page);
await auth.loginAsSuperAdmin({});
await page.context().storageState({ path: superAdminAuthFile });
});

View File

@@ -1,22 +1,33 @@
import { Page, expect } from '@playwright/test'; import { createClient } from '@supabase/supabase-js';
import test, { Page, expect } from '@playwright/test';
import { AUTH_STATES } from '../utils/auth-state';
import { Mailbox } from '../utils/mailbox'; import { Mailbox } from '../utils/mailbox';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
export class AuthPageObject { export class AuthPageObject {
private readonly page: Page; private readonly page: Page;
private readonly mailbox: Mailbox; private readonly mailbox: Mailbox;
static MFA_KEY = MFA_KEY;
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.mailbox = new Mailbox(page); this.mailbox = new Mailbox(page);
} }
goToSignIn() { static setupSession(user: (typeof AUTH_STATES)[keyof typeof AUTH_STATES]) {
return this.page.goto('/auth/sign-in'); test.use({ storageState: user });
} }
goToSignUp() { goToSignIn(next?: string) {
return this.page.goto('/auth/sign-up'); return this.page.goto(`/auth/sign-in${next ? `?next=${next}` : ''}`);
}
goToSignUp(next?: string) {
return this.page.goto(`/auth/sign-up${next ? `?next=${next}` : ''}`);
} }
async signOut() { async signOut() {
@@ -25,7 +36,7 @@ export class AuthPageObject {
} }
async signIn(params: { email: string; password: string }) { async signIn(params: { email: string; password: string }) {
await this.page.waitForTimeout(500); await this.page.waitForTimeout(100);
await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -37,7 +48,7 @@ export class AuthPageObject {
password: string; password: string;
repeatPassword: string; repeatPassword: string;
}) { }) {
await this.page.waitForTimeout(500); await this.page.waitForTimeout(100);
await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -108,4 +119,60 @@ export class AuthPageObject {
await this.page.fill('[name="repeatPassword"]', password); await this.page.fill('[name="repeatPassword"]', password);
await this.page.click('[type="submit"]'); await this.page.click('[type="submit"]');
} }
async loginAsSuperAdmin(params: { next?: string }) {
await this.loginAsUser({
email: 'super-admin@makerkit.dev',
next: '/auth/verify',
});
// Complete MFA verification
await this.submitMFAVerification(MFA_KEY);
await this.page.waitForURL(params.next ?? '/home');
}
async bootstrapUser({
email,
password,
name,
}: {
email: string;
password?: string;
name: string;
}) {
const client = createClient(
'http://127.0.0.1:54321',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU',
);
const { data, error } = await client.auth.admin.createUser({
email,
password: password || 'testingpassword',
email_confirm: true,
user_metadata: {
name,
},
});
if (error) {
throw new Error(`Failed to create user: ${error.message}`);
}
return data;
}
async loginAsUser(params: {
email: string;
password?: string;
next?: string;
}) {
await this.goToSignIn(params.next);
await this.signIn({
email: params.email,
password: params.password || 'testingpassword',
});
await this.page.waitForURL(params.next ?? '**/home');
}
} }

View File

@@ -29,7 +29,9 @@ test.describe('Auth flow', () => {
await auth.visitConfirmEmailLink(email); await auth.visitConfirmEmailLink(email);
await page.waitForURL('**/home'); await page.waitForURL('**/home', {
timeout: 5_000,
});
}); });
test('will sign-in with the correct credentials', async ({ page }) => { test('will sign-in with the correct credentials', async ({ page }) => {
@@ -43,7 +45,9 @@ test.describe('Auth flow', () => {
password: 'password', password: 'password',
}); });
await page.waitForURL('**/home'); await page.waitForURL('**/home', {
timeout: 5_000,
});
expect(page.url()).toContain('/home'); expect(page.url()).toContain('/home');
@@ -62,7 +66,9 @@ test.describe('Auth flow', () => {
password: 'testingpassword', password: 'testingpassword',
}); });
await page.waitForURL('/home/settings'); await page.waitForURL('/home/settings', {
timeout: 5_000,
});
await auth.signOut(); await auth.signOut();
@@ -75,17 +81,14 @@ test.describe('Protected routes', () => {
page, page,
}) => { }) => {
const auth = new AuthPageObject(page); const auth = new AuthPageObject(page);
const path = '/home/settings';
await page.goto('/home/settings'); await page.goto(path);
await auth.signIn({ await auth.loginAsUser({
email: 'test@makerkit.dev', email: 'test@makerkit.dev',
password: 'testingpassword', next: path,
}); });
await page.waitForURL('/home/settings');
expect(page.url()).toContain('/home/settings');
}); });
test('will redirect to the sign-in page if not authenticated', async ({ test('will redirect to the sign-in page if not authenticated', async ({
@@ -115,7 +118,9 @@ test.describe('Last auth method tracking', () => {
}); });
await auth.visitConfirmEmailLink(testEmail); await auth.visitConfirmEmailLink(testEmail);
await page.waitForURL('**/home'); await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out // Sign out
await auth.signOut(); await auth.signOut();
@@ -169,7 +174,9 @@ test.describe('Last auth method tracking', () => {
password: 'password123', password: 'password123',
}); });
await page.waitForURL('**/home'); await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out and check the method is still tracked // Sign out and check the method is still tracked
await auth.signOut(); await auth.signOut();

View File

@@ -13,22 +13,12 @@ test.describe('Password Reset Flow', () => {
await expect(async () => { await expect(async () => {
email = auth.createRandomEmail(); email = auth.createRandomEmail();
await page.goto('/auth/sign-up'); auth.bootstrapUser({
await auth.signUp({
email, email,
password: 'password', password: 'password',
repeatPassword: 'password', name: 'Test User',
}); });
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Confirm your email',
});
await page.context().clearCookies();
await page.reload();
await page.goto('/auth/password-reset'); await page.goto('/auth/password-reset');
await page.fill('[name="email"]', email); await page.fill('[name="email"]', email);
@@ -59,13 +49,10 @@ test.describe('Password Reset Flow', () => {
await page.waitForURL('/'); await page.waitForURL('/');
await page.goto('/auth/sign-in'); await page.goto('/auth/sign-in');
await auth.signIn({ await auth.loginAsUser({
email, email,
password: newPassword, password: newPassword,
}); next: '/home',
await page.waitForURL('/home', {
timeout: 2000,
}); });
}); });
}); });

View File

@@ -1,13 +1,11 @@
import { Page, expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { InvitationsPageObject } from './invitations.po'; import { InvitationsPageObject } from './invitations.po';
test.describe('Invitations', () => { test.describe('Invitations', () => {
let page: Page;
let invitations: InvitationsPageObject; let invitations: InvitationsPageObject;
test.beforeAll(async ({ browser }) => { test.beforeEach(async ({ page }) => {
page = await browser.newPage();
invitations = new InvitationsPageObject(page); invitations = new InvitationsPageObject(page);
await invitations.setup(); await invitations.setup();
@@ -88,17 +86,12 @@ test.describe('Invitations', () => {
}); });
test.describe('Full Invitation Flow', () => { test.describe('Full Invitation Flow', () => {
let page: Page; test('should invite users and let users accept an invite', async ({
let invitations: InvitationsPageObject; page,
}) => {
test.beforeAll(async ({ browser }) => { const invitations = new InvitationsPageObject(page);
page = await browser.newPage();
invitations = new InvitationsPageObject(page);
await invitations.setup(); await invitations.setup();
});
test('should invite users and let users accept an invite', async () => {
await invitations.navigateToMembers(); await invitations.navigateToMembers();
const invites = [ const invites = [

View File

@@ -15,11 +15,24 @@ export class TeamAccountsPageObject {
} }
async setup(params = this.createTeamName()) { async setup(params = this.createTeamName()) {
const { email } = await this.auth.signUpFlow('/home'); const auth = new AuthPageObject(this.page);
const email = auth.createRandomEmail();
await auth.bootstrapUser({
email,
name: 'Test User',
});
await auth.loginAsUser({ email });
await this.createTeam(params); await this.createTeam(params);
return { email, teamName: params.teamName, slug: params.slug }; return {
email: email,
teamName: params.teamName,
slug: params.slug,
};
} }
getTeamFromSelector(teamName: string) { getTeamFromSelector(teamName: string) {
@@ -81,11 +94,12 @@ export class TeamAccountsPageObject {
async tryCreateTeam(teamName: string) { async tryCreateTeam(teamName: string) {
await this.page.locator('[data-test="create-team-form"] input').fill(''); await this.page.locator('[data-test="create-team-form"] input').fill('');
await this.page.waitForTimeout(200); await this.page.waitForTimeout(200);
await this.page.locator('[data-test="create-team-form"] input').fill(teamName);
return this.page.click( await this.page
'[data-test="create-team-form"] button:last-child', .locator('[data-test="create-team-form"] input')
); .fill(teamName);
return this.page.click('[data-test="create-team-form"] button:last-child');
} }
async createTeam({ teamName, slug } = this.createTeamName()) { async createTeam({ teamName, slug } = this.createTeamName()) {
@@ -150,13 +164,13 @@ export class TeamAccountsPageObject {
await this.page.click('[data-test="role-selector-trigger"]'); await this.page.click('[data-test="role-selector-trigger"]');
await this.page.click(`[data-test="role-option-${newRole}"]`); await this.page.click(`[data-test="role-option-${newRole}"]`);
// Click the confirm button
const click = this.page.click('[data-test="confirm-update-member-role"]');
// Wait for the update to complete and page to reload // Wait for the update to complete and page to reload
const response = this.page.waitForURL('**/home/*/members'); const response = this.page.waitForResponse('**/members');
return Promise.all([click, response]); return Promise.all([
this.page.click('[data-test="confirm-update-member-role"]'),
response,
]);
}).toPass(); }).toPass();
} }
@@ -172,15 +186,13 @@ export class TeamAccountsPageObject {
// Complete OTP verification // Complete OTP verification
await this.otp.completeOtpVerification(ownerEmail); await this.otp.completeOtpVerification(ownerEmail);
// Click the confirm button
const click = this.page.click(
'[data-test="confirm-transfer-ownership-button"]',
);
// Wait for the transfer to complete and page to reload // Wait for the transfer to complete and page to reload
const response = this.page.waitForURL('**/home/*/members'); const response = this.page.waitForResponse('**/members');
return Promise.all([click, response]); return Promise.all([
this.page.click('[data-test="confirm-transfer-ownership-button"]'),
response,
]);
}).toPass(); }).toPass();
} }

View File

@@ -56,13 +56,11 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
await page.goto('/auth/sign-in'); await page.goto('/auth/sign-in');
await invitations.auth.signIn({ await invitations.auth.loginAsUser({
email: ownerEmail, email: ownerEmail,
password: 'password', next: '/home',
}); });
await page.waitForURL('/home');
// Navigate to the team members page // Navigate to the team members page
await page.goto(`/home/${slug}/members`); await page.goto(`/home/${slug}/members`);
@@ -70,17 +68,13 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
} }
test.describe('Team Accounts', () => { test.describe('Team Accounts', () => {
let page: Page; test.beforeEach(async ({ page }) => {
let teamAccounts: TeamAccountsPageObject; const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.setup();
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
teamAccounts = new TeamAccountsPageObject(page);
}); });
test('user can update their team name (and slug)', async () => { test('user can update their team name (and slug)', async ({ page }) => {
await teamAccounts.setup(); const teamAccounts = new TeamAccountsPageObject(page);
const { teamName, slug } = teamAccounts.createTeamName(); const { teamName, slug } = teamAccounts.createTeamName();
await teamAccounts.goToSettings(); await teamAccounts.goToSettings();
@@ -101,7 +95,7 @@ test.describe('Team Accounts', () => {
page, page,
}) => { }) => {
const teamAccounts = new TeamAccountsPageObject(page); const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.setup(); await teamAccounts.createTeam();
await teamAccounts.openAccountsSelector(); await teamAccounts.openAccountsSelector();
await page.click('[data-test="create-team-account-trigger"]'); await page.click('[data-test="create-team-account-trigger"]');
@@ -132,16 +126,16 @@ test.describe('Team Accounts', () => {
await teamAccounts.tryCreateTeam('Test,Name'); await teamAccounts.tryCreateTeam('Test,Name');
await expectError(); await expectError();
await teamAccounts.tryCreateTeam('Test Name/') await teamAccounts.tryCreateTeam('Test Name/');
await expectError(); await expectError();
await teamAccounts.tryCreateTeam('Test Name\\') await teamAccounts.tryCreateTeam('Test Name\\');
await expectError(); await expectError();
await teamAccounts.tryCreateTeam('Test Name:') await teamAccounts.tryCreateTeam('Test Name:');
await expectError(); await expectError();
await teamAccounts.tryCreateTeam('Test Name;') await teamAccounts.tryCreateTeam('Test Name;');
await expectError(); await expectError();
await teamAccounts.tryCreateTeam('Test Name='); await teamAccounts.tryCreateTeam('Test Name=');
@@ -225,14 +219,11 @@ test.describe('Team Member Role Management', () => {
// Update the member's role to admin // Update the member's role to admin
await teamAccounts.updateMemberRole(memberEmail, 'owner'); await teamAccounts.updateMemberRole(memberEmail, 'owner');
// Wait for the page to fully load after the update await expect(
await page.waitForTimeout(1000); page
// Verify the role was updated successfully
const updatedRoleBadge = page
.getByRole('row', { name: memberEmail }) .getByRole('row', { name: memberEmail })
.locator('[data-test="member-role-badge"]'); .locator('[data-test="member-role-badge"]'),
await expect(updatedRoleBadge).toHaveText('Owner'); ).toHaveText('Owner');
}); });
}); });
@@ -250,7 +241,7 @@ test.describe('Team Ownership Transfer', () => {
await teamAccounts.transferOwnership(memberEmail, ownerEmail); await teamAccounts.transferOwnership(memberEmail, ownerEmail);
// Wait for the page to fully load after the transfer // Wait for the page to fully load after the transfer
await page.waitForTimeout(1000); await page.waitForTimeout(500);
// Verify the transfer was successful by checking if the primary owner badge // Verify the transfer was successful by checking if the primary owner badge
// is now on the new owner's row // is now on the new owner's row

View File

@@ -4,8 +4,6 @@ import { AuthPageObject } from '../authentication/auth.po';
import { InvitationsPageObject } from '../invitations/invitations.po'; import { InvitationsPageObject } from '../invitations/invitations.po';
import { TeamAccountsPageObject } from './team-accounts.po'; import { TeamAccountsPageObject } from './team-accounts.po';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
test.describe('Team Invitation with MFA Flow', () => { test.describe('Team Invitation with MFA Flow', () => {
test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({ test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({
page, page,
@@ -14,18 +12,12 @@ test.describe('Team Invitation with MFA Flow', () => {
const teamAccounts = new TeamAccountsPageObject(page); const teamAccounts = new TeamAccountsPageObject(page);
const invitations = new InvitationsPageObject(page); const invitations = new InvitationsPageObject(page);
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`; await auth.loginAsUser({
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
// Step 1: test@makerkit.dev creates a team and sends invitation
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'test@makerkit.dev', email: 'test@makerkit.dev',
password: 'testingpassword',
}); });
await page.waitForURL('/home'); const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
// Create a new team // Create a new team
await teamAccounts.createTeam({ await teamAccounts.createTeam({
@@ -46,19 +38,24 @@ test.describe('Team Invitation with MFA Flow', () => {
// Verify invitation was sent // Verify invitation was sent
await expect(invitations.getInvitations()).toHaveCount(1); await expect(invitations.getInvitations()).toHaveCount(1);
const invitationRow = invitations.getInvitationRow( const invitationRow = invitations.getInvitationRow(
'super-admin@makerkit.dev', 'super-admin@makerkit.dev',
); );
await expect(invitationRow).toBeVisible(); await expect(invitationRow).toBeVisible();
await expect(async () => {
// Sign out test@makerkit.dev // Sign out test@makerkit.dev
await auth.signOut(); await auth.signOut();
await page.waitForURL('/');
// Step 2: super-admin@makerkit.dev signs in with MFA await page.waitForURL('/', {
await page.context().clearCookies(); timeout: 5_000,
});
}).toPass();
await auth.visitConfirmEmailLink('super-admin@makerkit.dev'); await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
await page await page
.locator('[data-test="existing-account-hint"]') .locator('[data-test="existing-account-hint"]')
.getByRole('link', { name: 'Already have an account?' }) .getByRole('link', { name: 'Already have an account?' })
@@ -71,7 +68,7 @@ test.describe('Team Invitation with MFA Flow', () => {
// Complete MFA verification // Complete MFA verification
await expect(async () => { await expect(async () => {
await auth.submitMFAVerification(MFA_KEY); await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
}).toPass({ }).toPass({
intervals: [ intervals: [
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000, 500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
@@ -91,6 +88,7 @@ test.describe('Team Invitation with MFA Flow', () => {
// Step 4: Verify membership was successful // Step 4: Verify membership was successful
// Open account selector to verify team is available // Open account selector to verify team is available
await teamAccounts.openAccountsSelector(); await teamAccounts.openAccountsSelector();
const team = teamAccounts.getTeamFromSelector(teamName); const team = teamAccounts.getTeamFromSelector(teamName);
await expect(team).toBeVisible(); await expect(team).toBeVisible();

View File

@@ -13,6 +13,6 @@ export class TeamBillingPageObject {
} }
setup() { setup() {
return this.teamAccounts.setup(); return this.teamAccounts.createTeam();
} }
} }

View File

@@ -1,18 +1,13 @@
import { Page, expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { TeamBillingPageObject } from './team-billing.po'; import { TeamBillingPageObject } from './team-billing.po';
test.describe('Team Billing', () => { test.describe('Team Billing', () => {
let page: Page; test('a team can subscribe to a plan', async ({ page }) => {
let po: TeamBillingPageObject; const po = new TeamBillingPageObject(page);
test.beforeAll(async ({ browser }) => { await po.teamAccounts.setup();
page = await browser.newPage();
po = new TeamBillingPageObject(page);
});
test('a team can subscribe to a plan', async () => {
await po.setup();
await po.teamAccounts.goToBilling(); await po.teamAccounts.goToBilling();
await po.billing.selectPlan(0); await po.billing.selectPlan(0);
@@ -23,7 +18,7 @@ test.describe('Team Billing', () => {
await po.billing.stripe.submitForm(); await po.billing.stripe.submitForm();
await expect(po.billing.successStatus()).toBeVisible({ await expect(po.billing.successStatus()).toBeVisible({
timeout: 25_000, timeout: 20_000,
}); });
await po.billing.returnToBilling(); await po.billing.returnToBilling();

View File

@@ -1,17 +1,11 @@
import { Page } from '@playwright/test'; import { Page } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { BillingPageObject } from '../utils/billing.po'; import { BillingPageObject } from '../utils/billing.po';
export class UserBillingPageObject { export class UserBillingPageObject {
private readonly auth: AuthPageObject;
public readonly billing: BillingPageObject; public readonly billing: BillingPageObject;
constructor(page: Page) { constructor(private readonly page: Page) {
this.auth = new AuthPageObject(page);
this.billing = new BillingPageObject(page); this.billing = new BillingPageObject(page);
} }
async setup() {
await this.auth.signUpFlow('/home/billing');
}
} }

View File

@@ -1,19 +1,25 @@
import { Page, expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { UserBillingPageObject } from './user-billing.po'; import { UserBillingPageObject } from './user-billing.po';
test.describe('User Billing', () => { test.describe('User Billing', () => {
let page: Page; test('user can subscribe to a plan', async ({ page }) => {
let po: UserBillingPageObject; const po = new UserBillingPageObject(page);
const auth = new AuthPageObject(page);
test.beforeAll(async ({ browser }) => { const email = auth.createRandomEmail();
page = await browser.newPage();
po = new UserBillingPageObject(page);
await po.setup(); await auth.bootstrapUser({
email,
name: 'Test Billing User',
});
await auth.loginAsUser({
email,
next: '/home/billing',
}); });
test('user can subscribe to a plan', async ({ page }) => {
await po.billing.selectPlan(0); await po.billing.selectPlan(0);
await po.billing.proceedToCheckout(); await po.billing.proceedToCheckout();

View File

@@ -0,0 +1,10 @@
import { join } from 'node:path';
import { cwd } from 'node:process';
export const AUTH_STATES = {
TEST_USER: join(cwd(), '.auth/test@makerkit.dev.json'),
OWNER_USER: join(cwd(), '.auth/owner@makerkit.dev.json'),
SUPER_ADMIN: join(cwd(), '.auth/super-admin@makerkit.dev.json'),
} as const;
export type AuthState = keyof typeof AUTH_STATES;

View File

@@ -17,13 +17,15 @@ export class StripePageObject {
}).toPass(); }).toPass();
} }
async fillForm(params: { async fillForm(
params: {
billingName?: string; billingName?: string;
cardNumber?: string; cardNumber?: string;
expiry?: string; expiry?: string;
cvc?: string; cvc?: string;
billingCountry?: string; billingCountry?: string;
} = {}) { } = {},
) {
const billingName = this.billingName(); const billingName = this.billingName();
const cardNumber = this.cardNumber(); const cardNumber = this.cardNumber();
const expiry = this.expiry(); const expiry = this.expiry();
@@ -38,7 +40,9 @@ export class StripePageObject {
} }
submitForm() { submitForm() {
return this.getStripeCheckoutIframe().getByTestId('hosted-payment-submit-button').click(); return this.getStripeCheckoutIframe()
.locator('[data-testid="hosted-payment-submit-button"]')
.click();
} }
cardNumber() { cardNumber() {

View File

@@ -220,10 +220,6 @@ function ActionsDropdown({
currentTeamAccountId: string; currentTeamAccountId: string;
currentRoleHierarchy: number; currentRoleHierarchy: number;
}) { }) {
const [isRemoving, setIsRemoving] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
const isCurrentUser = member.user_id === currentUserId; const isCurrentUser = member.user_id === currentUserId;
const isPrimaryOwner = member.primary_owner_user_id === member.user_id; const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
@@ -258,54 +254,42 @@ function ActionsDropdown({
<DropdownMenuContent> <DropdownMenuContent>
<If condition={canUpdateRole}> <If condition={canUpdateRole}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</If>
<If condition={permissions.canTransferOwnership}>
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</If>
<If condition={canRemoveFromAccount}>
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
<If condition={isRemoving}>
<RemoveMemberDialog
isOpen
setIsOpen={setIsRemoving}
teamAccountId={currentTeamAccountId}
userId={member.user_id}
/>
</If>
<If condition={isUpdatingRole}>
<UpdateMemberRoleDialog <UpdateMemberRoleDialog
isOpen
setIsOpen={setIsUpdatingRole}
userId={member.user_id} userId={member.user_id}
userRole={member.role} userRole={member.role}
teamAccountId={currentTeamAccountId} teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy} userRoleHierarchy={currentRoleHierarchy}
/> >
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</UpdateMemberRoleDialog>
</If> </If>
<If condition={isTransferring}> <If condition={permissions.canTransferOwnership}>
<TransferOwnershipDialog <TransferOwnershipDialog
isOpen
setIsOpen={setIsTransferring}
targetDisplayName={member.name ?? member.email} targetDisplayName={member.name ?? member.email}
accountId={member.account_id} accountId={member.account_id}
userId={member.user_id} userId={member.user_id}
/> >
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</TransferOwnershipDialog>
</If> </If>
<If condition={canRemoveFromAccount}>
<RemoveMemberDialog
teamAccountId={currentTeamAccountId}
userId={member.user_id}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</RemoveMemberDialog>
</If>
</DropdownMenuContent>
</DropdownMenu>
</> </>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog'; } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
@@ -17,18 +18,17 @@ import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions'; import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export function RemoveMemberDialog({ export function RemoveMemberDialog({
isOpen,
setIsOpen,
teamAccountId, teamAccountId,
userId, userId,
}: { children,
isOpen: boolean; }: React.PropsWithChildren<{
setIsOpen: (isOpen: boolean) => void;
teamAccountId: string; teamAccountId: string;
userId: string; userId: string;
}) { }>) {
return ( return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
@@ -40,11 +40,7 @@ export function RemoveMemberDialog({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<RemoveMemberForm <RemoveMemberForm accountId={teamAccountId} userId={userId} />
setIsOpen={setIsOpen}
accountId={teamAccountId}
userId={userId}
/>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); );
@@ -53,11 +49,9 @@ export function RemoveMemberDialog({
function RemoveMemberForm({ function RemoveMemberForm({
accountId, accountId,
userId, userId,
setIsOpen,
}: { }: {
accountId: string; accountId: string;
userId: string; userId: string;
setIsOpen: (isOpen: boolean) => void;
}) { }) {
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
@@ -66,8 +60,6 @@ function RemoveMemberForm({
startTransition(async () => { startTransition(async () => {
try { try {
await removeMemberFromAccountAction({ accountId, userId }); await removeMemberFromAccountAction({ accountId, userId });
setIsOpen(false);
} catch { } catch {
setError(true); setError(true);
} }

View File

@@ -16,6 +16,7 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog'; } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form'; import { Form } from '@kit/ui/form';
@@ -26,20 +27,20 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions'; import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export function TransferOwnershipDialog({ export function TransferOwnershipDialog({
isOpen, children,
setIsOpen,
targetDisplayName, targetDisplayName,
accountId, accountId,
userId, userId,
}: { }: {
isOpen: boolean; children: React.ReactNode;
setIsOpen: (isOpen: boolean) => void;
accountId: string; accountId: string;
userId: string; userId: string;
targetDisplayName: string; targetDisplayName: string;
}) { }) {
return ( return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}> <AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
@@ -55,7 +56,6 @@ export function TransferOwnershipDialog({
accountId={accountId} accountId={accountId}
userId={userId} userId={userId}
targetDisplayName={targetDisplayName} targetDisplayName={targetDisplayName}
setIsOpen={setIsOpen}
/> />
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@@ -66,12 +66,10 @@ function TransferOrganizationOwnershipForm({
accountId, accountId,
userId, userId,
targetDisplayName, targetDisplayName,
setIsOpen,
}: { }: {
userId: string; userId: string;
accountId: string; accountId: string;
targetDisplayName: string; targetDisplayName: string;
setIsOpen: (isOpen: boolean) => void;
}) { }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
@@ -121,8 +119,6 @@ function TransferOrganizationOwnershipForm({
startTransition(async () => { startTransition(async () => {
try { try {
await transferOwnershipAction(data); await transferOwnershipAction(data);
setIsOpen(false);
} catch { } catch {
setError(true); setError(true);
} }

View File

@@ -12,6 +12,7 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog'; } from '@kit/ui/dialog';
import { import {
Form, Form,
@@ -33,22 +34,20 @@ import { RolesDataProvider } from './roles-data-provider';
type Role = string; type Role = string;
export function UpdateMemberRoleDialog({ export function UpdateMemberRoleDialog({
isOpen, children,
setIsOpen,
userId, userId,
teamAccountId, teamAccountId,
userRole, userRole,
userRoleHierarchy, userRoleHierarchy,
}: { }: React.PropsWithChildren<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
userId: string; userId: string;
teamAccountId: string; teamAccountId: string;
userRole: Role; userRole: Role;
userRoleHierarchy: number; userRoleHierarchy: number;
}) { }>) {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
@@ -63,7 +62,6 @@ export function UpdateMemberRoleDialog({
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}> <RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(data) => ( {(data) => (
<UpdateMemberForm <UpdateMemberForm
setIsOpen={setIsOpen}
userId={userId} userId={userId}
teamAccountId={teamAccountId} teamAccountId={teamAccountId}
userRole={userRole} userRole={userRole}
@@ -80,13 +78,11 @@ function UpdateMemberForm({
userId, userId,
userRole, userRole,
teamAccountId, teamAccountId,
setIsOpen,
roles, roles,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
userId: string; userId: string;
userRole: Role; userRole: Role;
teamAccountId: string; teamAccountId: string;
setIsOpen: (isOpen: boolean) => void;
roles: Role[]; roles: Role[];
}>) { }>) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -101,8 +97,6 @@ function UpdateMemberForm({
userId, userId,
role, role,
}); });
setIsOpen(false);
} catch { } catch {
setError(true); setError(true);
} }

3
pnpm-lock.yaml generated
View File

@@ -126,6 +126,9 @@ importers:
'@playwright/test': '@playwright/test':
specifier: ^1.55.0 specifier: ^1.55.0
version: 1.55.0 version: 1.55.0
'@supabase/supabase-js':
specifier: 2.57.4
version: 2.57.4
'@types/node': '@types/node':
specifier: ^24.5.2 specifier: ^24.5.2
version: 24.5.2 version: 24.5.2