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/
/blob-report/
/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",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"report": "playwright show-report",
"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"
},
"keywords": [],
"author": "",
"license": "ISC",
"author": "Makerkit",
"devDependencies": {
"@playwright/test": "^1.55.0",
"@supabase/supabase-js": "2.57.4",
"@types/node": "^24.5.2",
"dotenv": "17.2.2",
"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.
*/
const CI_WORKERS = 3;
const CI_WORKERS = 2;
const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true';
const enableTeamAccountTests =
(process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true';
@@ -50,7 +51,7 @@ export default defineConfig({
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
retries: 3,
retries: 2,
/* Limit parallel tests on CI. */
workers: process.env.CI ? CI_WORKERS : undefined,
/* 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 */
trace: 'on-first-retry',
navigationTimeout: 15 * 1000,
testIdAttribute: 'data-test',
},
// test timeout set to 2 minutes
timeout: 120 * 1000,
@@ -77,9 +79,11 @@ export default defineConfig({
},
/* Configure projects for major browsers */
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
/* Test against mobile viewports. */
// {

View File

@@ -14,10 +14,6 @@ export class AccountPageObject {
this.otp = new OtpPo(page);
}
async setup() {
return this.auth.signUpFlow('/home/settings');
}
async updateName(name: string) {
await this.page.fill('[data-test="update-account-name-form"] input', name);
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 {AuthPageObject} from "../authentication/auth.po";
test.describe('Account Settings', () => {
let page: Page;
let account: AccountPageObject;
let email: string;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
account = new AccountPageObject(page);
test.beforeEach(async ({ page }) => {
const auth = new AuthPageObject(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 request = account.updateName(name);
@@ -28,13 +41,13 @@ test.describe('Account Settings', () => {
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();
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 request = account.updatePassword(password);
@@ -53,10 +66,19 @@ test.describe('Account Settings', () => {
test.describe('Account Deletion', () => {
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 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);
@@ -70,6 +92,8 @@ test.describe('Account Deletion', () => {
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 { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
import { AUTH_STATES } from '../utils/auth-state';
test.describe('Admin Auth flow without MFA', () => {
AuthPageObject.setupSession(AUTH_STATES.OWNER_USER);
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');
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');
expect(page.url()).toContain('/404');
@@ -42,12 +25,13 @@ test.describe('Admin Auth flow without MFA', () => {
});
test.describe('Admin', () => {
// must be serial because OTP verification is not working in parallel
test.describe.configure({ mode: 'serial' });
test.describe('Admin Dashboard', () => {
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
test('displays all stat cards', async ({ page }) => {
await goToAdmin(page);
await page.goto('/admin');
// Check all stat cards are present
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
@@ -73,19 +57,14 @@ test.describe('Admin', () => {
});
test.describe('Personal Account Management', () => {
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
let testUserEmail: string;
test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test');
// Create a new test user before each test
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`);
// use the email as the filter text
@@ -99,6 +78,7 @@ test.describe('Admin', () => {
await expect(page.getByText('Personal Account')).toBeVisible();
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
await expect(
page.getByTestId('admin-delete-account-button'),
).toBeVisible();
@@ -106,6 +86,7 @@ test.describe('Admin', () => {
test('ban user flow', async ({ page }) => {
await page.getByTestId('admin-ban-account-button').click();
await expect(
page.getByRole('heading', { name: 'Ban User' }),
).toBeVisible();
@@ -189,29 +170,14 @@ test.describe('Admin', () => {
const auth = new AuthPageObject(page);
await auth.signIn({
await auth.loginAsUser({
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 }) => {
const auth = new AuthPageObject(page);
await page.getByTestId('admin-delete-account-button').click();
await expect(
@@ -236,13 +202,10 @@ test.describe('Admin', () => {
await page.waitForURL('/admin/accounts');
// Log out
await page.context().clearCookies();
await auth.signOut();
await page.waitForURL('/');
// Verify user can't log in
await page.goto('/auth/sign-in');
const auth = new AuthPageObject(page);
await auth.goToSignIn();
await auth.signIn({
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.configure({ mode: 'serial' });
test.skip(
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
'Team account tests are disabled',
@@ -267,13 +259,15 @@ test.describe('Admin', () => {
let slug: string;
test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test');
const auth = new AuthPageObject(page);
// Create a new test user and team account
testUserEmail = await createUser(page, {
afterSignIn: async () => {
testUserEmail = await createUser(page);
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
await auth.loginAsUser({ email: testUserEmail });
const teamAccountPo = new TeamAccountsPageObject(page);
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
@@ -283,10 +277,13 @@ test.describe('Admin', () => {
teamName,
slug,
});
},
});
await goToAdmin(page);
await page.waitForTimeout(250);
await auth.signOut();
await page.waitForURL('/');
await auth.loginAsSuperAdmin({});
await page.goto(`/admin/accounts`);
@@ -294,15 +291,11 @@ test.describe('Admin', () => {
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 }) => {
await expect(page.getByText('Team Account')).toBeVisible();
await page.getByTestId('admin-delete-account-button').click();
await expect(
page.getByRole('heading', { name: 'Delete Account' }),
).toBeVisible();
@@ -322,64 +315,20 @@ test.describe('Admin', () => {
await expect(page).toHaveURL('/admin/accounts');
});
});
});
async function goToAdmin(page: Page) {
async function createUser(page: 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 email = auth.createRandomEmail();
// sign up
await page.goto('/auth/sign-up');
await auth.signUp({
// create user using bootstrap method
await auth.bootstrapUser({
email,
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 email;
}
@@ -404,6 +353,6 @@ async function selectAccount(page: Page, email: string) {
await link.click();
await page.waitForLoadState('networkidle');
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
}).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';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
export class AuthPageObject {
private readonly page: Page;
private readonly mailbox: Mailbox;
static MFA_KEY = MFA_KEY;
constructor(page: Page) {
this.page = page;
this.mailbox = new Mailbox(page);
}
goToSignIn() {
return this.page.goto('/auth/sign-in');
static setupSession(user: (typeof AUTH_STATES)[keyof typeof AUTH_STATES]) {
test.use({ storageState: user });
}
goToSignUp() {
return this.page.goto('/auth/sign-up');
goToSignIn(next?: string) {
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() {
@@ -25,7 +36,7 @@ export class AuthPageObject {
}
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="password"]', params.password);
@@ -37,7 +48,7 @@ export class AuthPageObject {
password: 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="password"]', params.password);
@@ -108,4 +119,60 @@ export class AuthPageObject {
await this.page.fill('[name="repeatPassword"]', password);
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 page.waitForURL('**/home');
await page.waitForURL('**/home', {
timeout: 5_000,
});
});
test('will sign-in with the correct credentials', async ({ page }) => {
@@ -43,7 +45,9 @@ test.describe('Auth flow', () => {
password: 'password',
});
await page.waitForURL('**/home');
await page.waitForURL('**/home', {
timeout: 5_000,
});
expect(page.url()).toContain('/home');
@@ -62,7 +66,9 @@ test.describe('Auth flow', () => {
password: 'testingpassword',
});
await page.waitForURL('/home/settings');
await page.waitForURL('/home/settings', {
timeout: 5_000,
});
await auth.signOut();
@@ -75,17 +81,14 @@ test.describe('Protected routes', () => {
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',
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 ({
@@ -115,7 +118,9 @@ test.describe('Last auth method tracking', () => {
});
await auth.visitConfirmEmailLink(testEmail);
await page.waitForURL('**/home');
await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out
await auth.signOut();
@@ -169,7 +174,9 @@ test.describe('Last auth method tracking', () => {
password: 'password123',
});
await page.waitForURL('**/home');
await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out and check the method is still tracked
await auth.signOut();

View File

@@ -13,22 +13,12 @@ test.describe('Password Reset Flow', () => {
await expect(async () => {
email = auth.createRandomEmail();
await page.goto('/auth/sign-up');
await auth.signUp({
auth.bootstrapUser({
email,
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.fill('[name="email"]', email);
@@ -59,13 +49,10 @@ test.describe('Password Reset Flow', () => {
await page.waitForURL('/');
await page.goto('/auth/sign-in');
await auth.signIn({
await auth.loginAsUser({
email,
password: newPassword,
});
await page.waitForURL('/home', {
timeout: 2000,
next: '/home',
});
});
});

View File

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

View File

@@ -15,11 +15,24 @@ export class TeamAccountsPageObject {
}
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);
return { email, teamName: params.teamName, slug: params.slug };
return {
email: email,
teamName: params.teamName,
slug: params.slug,
};
}
getTeamFromSelector(teamName: string) {
@@ -81,11 +94,12 @@ export class TeamAccountsPageObject {
async tryCreateTeam(teamName: string) {
await this.page.locator('[data-test="create-team-form"] input').fill('');
await this.page.waitForTimeout(200);
await this.page.locator('[data-test="create-team-form"] input').fill(teamName);
return this.page.click(
'[data-test="create-team-form"] button:last-child',
);
await this.page
.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()) {
@@ -150,13 +164,13 @@ export class TeamAccountsPageObject {
await this.page.click('[data-test="role-selector-trigger"]');
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
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();
}
@@ -172,15 +186,13 @@ export class TeamAccountsPageObject {
// Complete OTP verification
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
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();
}

View File

@@ -56,13 +56,11 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
await page.goto('/auth/sign-in');
await invitations.auth.signIn({
await invitations.auth.loginAsUser({
email: ownerEmail,
password: 'password',
next: '/home',
});
await page.waitForURL('/home');
// Navigate to the team members page
await page.goto(`/home/${slug}/members`);
@@ -70,17 +68,13 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
}
test.describe('Team Accounts', () => {
let page: Page;
let teamAccounts: TeamAccountsPageObject;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
teamAccounts = new TeamAccountsPageObject(page);
test.beforeEach(async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.setup();
});
test('user can update their team name (and slug)', async () => {
await teamAccounts.setup();
test('user can update their team name (and slug)', async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page);
const { teamName, slug } = teamAccounts.createTeamName();
await teamAccounts.goToSettings();
@@ -101,7 +95,7 @@ test.describe('Team Accounts', () => {
page,
}) => {
const teamAccounts = new TeamAccountsPageObject(page);
await teamAccounts.setup();
await teamAccounts.createTeam();
await teamAccounts.openAccountsSelector();
await page.click('[data-test="create-team-account-trigger"]');
@@ -132,16 +126,16 @@ test.describe('Team Accounts', () => {
await teamAccounts.tryCreateTeam('Test,Name');
await expectError();
await teamAccounts.tryCreateTeam('Test Name/')
await teamAccounts.tryCreateTeam('Test Name/');
await expectError();
await teamAccounts.tryCreateTeam('Test Name\\')
await teamAccounts.tryCreateTeam('Test Name\\');
await expectError();
await teamAccounts.tryCreateTeam('Test Name:')
await teamAccounts.tryCreateTeam('Test Name:');
await expectError();
await teamAccounts.tryCreateTeam('Test Name;')
await teamAccounts.tryCreateTeam('Test Name;');
await expectError();
await teamAccounts.tryCreateTeam('Test Name=');
@@ -225,14 +219,11 @@ test.describe('Team Member Role Management', () => {
// Update the member's role to admin
await teamAccounts.updateMemberRole(memberEmail, 'owner');
// Wait for the page to fully load after the update
await page.waitForTimeout(1000);
// Verify the role was updated successfully
const updatedRoleBadge = page
await expect(
page
.getByRole('row', { name: memberEmail })
.locator('[data-test="member-role-badge"]');
await expect(updatedRoleBadge).toHaveText('Owner');
.locator('[data-test="member-role-badge"]'),
).toHaveText('Owner');
});
});
@@ -250,7 +241,7 @@ test.describe('Team Ownership Transfer', () => {
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
// 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
// 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 { TeamAccountsPageObject } from './team-accounts.po';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
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 ({
page,
@@ -14,18 +12,12 @@ test.describe('Team Invitation with MFA Flow', () => {
const teamAccounts = new TeamAccountsPageObject(page);
const invitations = new InvitationsPageObject(page);
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
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({
await auth.loginAsUser({
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
await teamAccounts.createTeam({
@@ -46,19 +38,24 @@ test.describe('Team Invitation with MFA Flow', () => {
// Verify invitation was sent
await expect(invitations.getInvitations()).toHaveCount(1);
const invitationRow = invitations.getInvitationRow(
'super-admin@makerkit.dev',
);
await expect(invitationRow).toBeVisible();
await expect(async () => {
// Sign out test@makerkit.dev
await auth.signOut();
await page.waitForURL('/');
// Step 2: super-admin@makerkit.dev signs in with MFA
await page.context().clearCookies();
await page.waitForURL('/', {
timeout: 5_000,
});
}).toPass();
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
await page
.locator('[data-test="existing-account-hint"]')
.getByRole('link', { name: 'Already have an account?' })
@@ -71,7 +68,7 @@ test.describe('Team Invitation with MFA Flow', () => {
// Complete MFA verification
await expect(async () => {
await auth.submitMFAVerification(MFA_KEY);
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
}).toPass({
intervals: [
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
// Open account selector to verify team is available
await teamAccounts.openAccountsSelector();
const team = teamAccounts.getTeamFromSelector(teamName);
await expect(team).toBeVisible();

View File

@@ -13,6 +13,6 @@ export class TeamBillingPageObject {
}
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';
test.describe('Team Billing', () => {
let page: Page;
let po: TeamBillingPageObject;
test('a team can subscribe to a plan', async ({ page }) => {
const po = new TeamBillingPageObject(page);
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
po = new TeamBillingPageObject(page);
});
test('a team can subscribe to a plan', async () => {
await po.setup();
await po.teamAccounts.setup();
await po.teamAccounts.goToBilling();
await po.billing.selectPlan(0);
@@ -23,7 +18,7 @@ test.describe('Team Billing', () => {
await po.billing.stripe.submitForm();
await expect(po.billing.successStatus()).toBeVisible({
timeout: 25_000,
timeout: 20_000,
});
await po.billing.returnToBilling();

View File

@@ -1,17 +1,11 @@
import { Page } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { BillingPageObject } from '../utils/billing.po';
export class UserBillingPageObject {
private readonly auth: AuthPageObject;
public readonly billing: BillingPageObject;
constructor(page: Page) {
this.auth = new AuthPageObject(page);
constructor(private readonly page: 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';
test.describe('User Billing', () => {
let page: Page;
let po: UserBillingPageObject;
test('user can subscribe to a plan', async ({ page }) => {
const po = new UserBillingPageObject(page);
const auth = new AuthPageObject(page);
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
po = new UserBillingPageObject(page);
const email = auth.createRandomEmail();
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.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();
}
async fillForm(params: {
async fillForm(
params: {
billingName?: string;
cardNumber?: string;
expiry?: string;
cvc?: string;
billingCountry?: string;
} = {}) {
} = {},
) {
const billingName = this.billingName();
const cardNumber = this.cardNumber();
const expiry = this.expiry();
@@ -38,7 +40,9 @@ export class StripePageObject {
}
submitForm() {
return this.getStripeCheckoutIframe().getByTestId('hosted-payment-submit-button').click();
return this.getStripeCheckoutIframe()
.locator('[data-testid="hosted-payment-submit-button"]')
.click();
}
cardNumber() {

View File

@@ -220,10 +220,6 @@ function ActionsDropdown({
currentTeamAccountId: string;
currentRoleHierarchy: number;
}) {
const [isRemoving, setIsRemoving] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
const isCurrentUser = member.user_id === currentUserId;
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
@@ -258,54 +254,42 @@ function ActionsDropdown({
<DropdownMenuContent>
<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
isOpen
setIsOpen={setIsUpdatingRole}
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
/>
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</UpdateMemberRoleDialog>
</If>
<If condition={isTransferring}>
<If condition={permissions.canTransferOwnership}>
<TransferOwnershipDialog
isOpen
setIsOpen={setIsTransferring}
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
/>
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</TransferOwnershipDialog>
</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,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
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';
export function RemoveMemberDialog({
isOpen,
setIsOpen,
teamAccountId,
userId,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
children,
}: React.PropsWithChildren<{
teamAccountId: string;
userId: string;
}) {
}>) {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -40,11 +40,7 @@ export function RemoveMemberDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<RemoveMemberForm
setIsOpen={setIsOpen}
accountId={teamAccountId}
userId={userId}
/>
<RemoveMemberForm accountId={teamAccountId} userId={userId} />
</AlertDialogContent>
</AlertDialog>
);
@@ -53,11 +49,9 @@ export function RemoveMemberDialog({
function RemoveMemberForm({
accountId,
userId,
setIsOpen,
}: {
accountId: string;
userId: string;
setIsOpen: (isOpen: boolean) => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
@@ -66,8 +60,6 @@ function RemoveMemberForm({
startTransition(async () => {
try {
await removeMemberFromAccountAction({ accountId, userId });
setIsOpen(false);
} catch {
setError(true);
}

View File

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

View File

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

3
pnpm-lock.yaml generated
View File

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