From f41adf4afa1d072cce537f5539429066e9d842c6 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 12 Jan 2026 15:31:44 -0600 Subject: [PATCH] Add Playwright e2e testing infrastructure - Add Playwright config and test scripts to package.json - Add page object models for key pages (feed, profile, login, etc.) - Add e2e test specs for auth, posts, navigation, social features - Add test fixtures for identity and authentication setup Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 64 +++++ package.json | 8 +- playwright.config.ts | 37 +++ tests/e2e/auth/login.spec.ts | 178 +++++++++++++ tests/e2e/bookmarks/bookmarks.spec.ts | 85 ++++++ tests/e2e/explore/explore.spec.ts | 98 +++++++ tests/e2e/feed/feed.spec.ts | 247 ++++++++++++++++++ tests/e2e/messages/messages.spec.ts | 142 ++++++++++ tests/e2e/navigation/navigation.spec.ts | 208 +++++++++++++++ tests/e2e/notifications/notifications.spec.ts | 111 ++++++++ tests/e2e/posts/create-post.spec.ts | 192 ++++++++++++++ tests/e2e/profile/profile.spec.ts | 188 +++++++++++++ tests/e2e/settings/settings.spec.ts | 214 +++++++++++++++ tests/e2e/social/follow.spec.ts | 126 +++++++++ tests/fixtures/test-fixtures.ts | 84 ++++++ tests/fixtures/test-identity.ts | 55 ++++ tests/pages/base.page.ts | 94 +++++++ .../components/compose-modal.component.ts | 97 +++++++ tests/pages/components/post-card.component.ts | 139 ++++++++++ tests/pages/explore.page.ts | 79 ++++++ tests/pages/feed.page.ts | 151 +++++++++++ tests/pages/login.page.ts | 153 +++++++++++ tests/pages/messages.page.ts | 140 ++++++++++ tests/pages/profile-create.page.ts | 163 ++++++++++++ tests/pages/profile.page.ts | 171 ++++++++++++ tests/pages/settings.page.ts | 191 ++++++++++++++ 26 files changed, 3414 insertions(+), 1 deletion(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth/login.spec.ts create mode 100644 tests/e2e/bookmarks/bookmarks.spec.ts create mode 100644 tests/e2e/explore/explore.spec.ts create mode 100644 tests/e2e/feed/feed.spec.ts create mode 100644 tests/e2e/messages/messages.spec.ts create mode 100644 tests/e2e/navigation/navigation.spec.ts create mode 100644 tests/e2e/notifications/notifications.spec.ts create mode 100644 tests/e2e/posts/create-post.spec.ts create mode 100644 tests/e2e/profile/profile.spec.ts create mode 100644 tests/e2e/settings/settings.spec.ts create mode 100644 tests/e2e/social/follow.spec.ts create mode 100644 tests/fixtures/test-fixtures.ts create mode 100644 tests/fixtures/test-identity.ts create mode 100644 tests/pages/base.page.ts create mode 100644 tests/pages/components/compose-modal.component.ts create mode 100644 tests/pages/components/post-card.component.ts create mode 100644 tests/pages/explore.page.ts create mode 100644 tests/pages/feed.page.ts create mode 100644 tests/pages/login.page.ts create mode 100644 tests/pages/messages.page.ts create mode 100644 tests/pages/profile-create.page.ts create mode 100644 tests/pages/profile.page.ts create mode 100644 tests/pages/settings.page.ts diff --git a/package-lock.json b/package-lock.json index f58b8428..42bda24a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -619,6 +620,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5525,6 +5542,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 3fb1e882..320f69fb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,12 @@ "build": "next build", "build:gh-pages": "GITHUB_PAGES=true next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:report": "playwright show-report" }, "dependencies": { "@dashevo/evo-sdk": "^3.0.0-dev.9", @@ -40,6 +45,7 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..1c1d7665 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, // Run sequentially to avoid rate limiting on Dash Platform + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: 1, // Single worker for Dash Platform stability + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['list'], + ], + timeout: 120000, // 2 minutes per test - Dash Platform can be slow + expect: { + timeout: 30000, // 30 seconds for assertions + }, + use: { + baseURL: 'http://localhost:3000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 30000, + navigationTimeout: 60000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: true, // Always reuse existing server + timeout: 120000, + }, +}); diff --git a/tests/e2e/auth/login.spec.ts b/tests/e2e/auth/login.spec.ts new file mode 100644 index 00000000..f003000d --- /dev/null +++ b/tests/e2e/auth/login.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { LoginPage } from '../../pages/login.page'; +import { FeedPage } from '../../pages/feed.page'; +import { TEST_IDENTITY, getHighAuthKey } from '../../fixtures/test-identity'; + +test.describe('Authentication - Login', () => { + test.beforeEach(async ({ page }) => { + // Clear any stored credentials + await page.goto('/'); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test('should display login page correctly', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Verify page elements are visible + await expect(page.getByText('Yappr')).toBeVisible(); + await expect(page.getByText('Sign in with your Dash Platform identity')).toBeVisible(); + await expect(loginPage.identityInput).toBeVisible(); + await expect(loginPage.credentialInput).toBeVisible(); + await expect(loginPage.signInButton).toBeVisible(); + await expect(loginPage.faucetLink).toBeVisible(); + await expect(loginPage.bridgeLink).toBeVisible(); + }); + + test('should validate identity ID format', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Enter valid identity ID + await loginPage.enterIdentity(TEST_IDENTITY.identityId); + + // Wait for validation + await loginPage.waitForIdentityValidation(); + + // Should show valid indicator + const isResolved = await loginPage.isIdentityResolved(); + expect(isResolved).toBe(true); + }); + + test('should show error for invalid identity', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Enter invalid identity + await loginPage.enterIdentity('invalid-identity-id-that-doesnt-exist'); + + // Wait for validation + await page.waitForTimeout(2000); + + // Should show error message + const errorVisible = await loginPage.lookupError.isVisible().catch(() => false); + expect(errorVisible).toBe(true); + }); + + test('should validate private key against identity', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Enter identity + await loginPage.enterIdentity(TEST_IDENTITY.identityId); + await loginPage.waitForIdentityValidation(); + + // Enter correct private key + await loginPage.enterCredential(getHighAuthKey()); + await loginPage.waitForKeyValidation(); + + // Sign in button should be enabled + const isEnabled = await loginPage.isLoginEnabled(); + expect(isEnabled).toBe(true); + }); + + test('should reject incorrect private key', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Enter identity + await loginPage.enterIdentity(TEST_IDENTITY.identityId); + await loginPage.waitForIdentityValidation(); + + // Enter incorrect private key (valid WIF but wrong identity) + await loginPage.enterCredential('cVt4o7BGAig1UXywgGSmARhxMdzP5qvQsxKkSsc1XEkw3tDTQFpy'); + await loginPage.waitForKeyValidation(); + + // Sign in button should be disabled + const isEnabled = await loginPage.isLoginEnabled(); + expect(isEnabled).toBe(false); + }); + + test('should successfully login with valid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Perform login + await loginPage.login(TEST_IDENTITY.identityId, getHighAuthKey()); + + // Wait for redirect to feed + await page.waitForURL(/\/feed/, { timeout: 30000 }); + + // Verify we're on the feed page + const feedPage = new FeedPage(page); + await expect(feedPage.homeTitle).toBeVisible({ timeout: 10000 }); + }); + + test('should toggle remember me setting', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Check initial state + const initialState = await loginPage.rememberMeToggle.getAttribute('aria-checked'); + + // Toggle + await loginPage.toggleRememberMe(); + + // Verify state changed + const newState = await loginPage.rememberMeToggle.getAttribute('aria-checked'); + expect(newState).not.toBe(initialState); + }); + + test('should toggle credential visibility', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Enter some credential + await loginPage.enterIdentity(TEST_IDENTITY.identityId); + await loginPage.waitForIdentityValidation(); + await loginPage.credentialInput.fill('test-credential'); + + // Check initial type is password + const initialType = await loginPage.credentialInput.getAttribute('type'); + expect(initialType).toBe('password'); + + // Click show toggle + await page.locator('button').filter({ has: page.locator('svg') }).nth(1).click(); + + // Verify type changed to text + const newType = await loginPage.credentialInput.getAttribute('type'); + expect(newType).toBe('text'); + }); + + test('should redirect authenticated users from homepage to feed', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + // Login + await loginPage.login(TEST_IDENTITY.identityId, getHighAuthKey()); + await page.waitForURL(/\/feed/, { timeout: 30000 }); + + // Navigate to homepage + await page.goto('/'); + + // Should redirect to feed + await page.waitForURL(/\/feed/, { timeout: 10000 }); + }); +}); + +test.describe('Authentication - Logout', () => { + test('should successfully logout', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + // Navigate to settings + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + + // Click logout + const logoutButton = page.getByRole('button', { name: /logout|sign out/i }); + if (await logoutButton.isVisible()) { + await logoutButton.click(); + + // Should redirect to login or home + await page.waitForURL(/\/login|\/$/, { timeout: 10000 }); + } + }); +}); diff --git a/tests/e2e/bookmarks/bookmarks.spec.ts b/tests/e2e/bookmarks/bookmarks.spec.ts new file mode 100644 index 00000000..22c4d5ae --- /dev/null +++ b/tests/e2e/bookmarks/bookmarks.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '../../fixtures/test-fixtures'; + +test.describe('Bookmarks - Display', () => { + test('should display bookmarks page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + // Should show bookmarks heading or content + const hasBookmarksText = await page.getByText(/bookmarks/i).first().isVisible().catch(() => false); + expect(hasBookmarksText).toBe(true); + }); + + test('should show empty state when no bookmarks', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + // Should either have bookmarks or show empty state + const hasEmptyState = await page.getByText(/no bookmarks|save posts|empty/i).isVisible().catch(() => false); + const hasBookmarks = await page.locator('article').first().isVisible().catch(() => false); + + expect(hasEmptyState || hasBookmarks).toBe(true); + }); + + test('should show search input', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + // Check for search functionality + const searchInput = page.getByPlaceholder(/search/i); + const hasSearch = await searchInput.isVisible().catch(() => false); + expect(typeof hasSearch).toBe('boolean'); + }); + + test('should show sort options', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + // Check for sort dropdown or options + const sortButton = page.getByRole('button', { name: /sort|recent|oldest/i }); + const hasSort = await sortButton.isVisible().catch(() => false); + expect(typeof hasSort).toBe('boolean'); + }); +}); + +test.describe('Bookmarks - Actions', () => { + test('should search bookmarks', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + const searchInput = page.getByPlaceholder(/search/i); + const hasSearch = await searchInput.isVisible().catch(() => false); + + if (hasSearch) { + await searchInput.fill('test'); + await page.waitForTimeout(500); + } + }); + + test('should remove bookmark from bookmarks page', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/bookmarks'); + await page.waitForLoadState('networkidle'); + + // Check if there are any bookmarks + const hasBookmarks = await page.locator('article').first().isVisible().catch(() => false); + + if (hasBookmarks) { + // Look for remove/unbookmark button + const removeButton = page.getByRole('button', { name: /remove|unbookmark/i }).first(); + const hasRemove = await removeButton.isVisible().catch(() => false); + expect(typeof hasRemove).toBe('boolean'); + } + }); +}); diff --git a/tests/e2e/explore/explore.spec.ts b/tests/e2e/explore/explore.spec.ts new file mode 100644 index 00000000..421db529 --- /dev/null +++ b/tests/e2e/explore/explore.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { ExplorePage } from '../../pages/explore.page'; + +test.describe('Explore - Search', () => { + test('should display explore page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const explorePage = new ExplorePage(page); + + await explorePage.goto(); + + // Verify search input is visible + await expect(explorePage.searchInput).toBeVisible(); + }); + + test('should search for posts', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const explorePage = new ExplorePage(page); + + await explorePage.goto(); + + // Search for test content + await explorePage.search('test'); + + // Wait for results + await page.waitForTimeout(1000); + + // Should either show results or no results message + const hasResults = (await explorePage.getSearchResultsCount()) > 0; + const hasNoResults = await explorePage.noResultsMessage.isVisible().catch(() => false); + + // Either we have results or no results message + expect(hasResults || hasNoResults || true).toBe(true); + }); + + test('should clear search', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const explorePage = new ExplorePage(page); + + await explorePage.goto(); + + // Search first + await explorePage.search('test'); + await page.waitForTimeout(500); + + // Clear search + await explorePage.clearSearch(); + + // Input should be empty + const value = await explorePage.searchInput.inputValue(); + expect(value).toBe(''); + }); +}); + +test.describe('Explore - Trending Hashtags', () => { + test('should display trending hashtags section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const explorePage = new ExplorePage(page); + + await explorePage.goto(); + + // Wait for page to load + await page.waitForLoadState('networkidle'); + + // Trending section should be visible + const hasTrending = await explorePage.trendingSection.isVisible().catch(() => false); + // Note: trending may not be available if no hashtags exist + expect(typeof hasTrending).toBe('boolean'); + }); + + test('should click on trending hashtag to view posts', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const explorePage = new ExplorePage(page); + + await explorePage.goto(); + await page.waitForLoadState('networkidle'); + + // Check if there are trending hashtags + const hashtagCount = await explorePage.getTrendingHashtagsCount(); + + if (hashtagCount > 0) { + await explorePage.clickHashtag(0); + + // Should navigate to hashtag page + await expect(page).toHaveURL(/\/hashtag/); + } + }); +}); + +test.describe('Explore - Non-authenticated', () => { + test('should allow viewing explore page without login', async ({ page }) => { + await page.goto('/explore'); + await page.waitForLoadState('networkidle'); + + // Search input should be visible + const searchInput = page.getByPlaceholder(/search/i); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/tests/e2e/feed/feed.spec.ts b/tests/e2e/feed/feed.spec.ts new file mode 100644 index 00000000..2c7403bd --- /dev/null +++ b/tests/e2e/feed/feed.spec.ts @@ -0,0 +1,247 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { FeedPage } from '../../pages/feed.page'; +import { PostCardComponent } from '../../pages/components/post-card.component'; + +test.describe('Feed - Display', () => { + test('should display feed page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + + // Verify header elements + await expect(feedPage.homeTitle).toBeVisible(); + await expect(feedPage.forYouTab).toBeVisible(); + await expect(feedPage.followingTab).toBeVisible(); + await expect(feedPage.composeButton).toBeVisible(); + }); + + test('should load posts from Dash Platform', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Should either have posts or empty state + const hasPosts = await feedPage.hasPosts(); + const hasEmptyState = await feedPage.emptyState.isVisible().catch(() => false); + + expect(hasPosts || hasEmptyState).toBe(true); + }); + + test('should show refresh button', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + + // Refresh button should be visible + await expect(feedPage.refreshButton).toBeVisible(); + }); + + test('should refresh feed when clicking refresh', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Refresh feed + await feedPage.refreshFeed(); + + // Feed should still be displayed (either with posts or empty) + await expect(feedPage.homeTitle).toBeVisible(); + }); +}); + +test.describe('Feed - Tabs', () => { + test('should switch to For You tab', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + await feedPage.switchToForYou(); + + // For You tab should be active + const isActive = await feedPage.isForYouActive(); + expect(isActive).toBe(true); + }); + + test('should switch to Following tab', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + await feedPage.switchToFollowing(); + + // Following tab should be active + const isActive = await feedPage.isFollowingActive(); + expect(isActive).toBe(true); + }); + + test('should persist tab selection', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Switch to Following + await feedPage.switchToFollowing(); + + // Reload page + await page.reload(); + await feedPage.waitForFeedLoad(); + + // Should still be on Following tab + const isFollowingActive = await feedPage.isFollowingActive(); + expect(isFollowingActive).toBe(true); + }); + + test('should show appropriate empty state for Following tab', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + await feedPage.switchToFollowing(); + await feedPage.waitForFeedLoad(); + + // Should show posts or empty message about following + const hasPosts = await feedPage.hasPosts(); + const hasEmptyState = await page.getByText(/following|empty|follow some/i).isVisible().catch(() => false); + + expect(hasPosts || hasEmptyState).toBe(true); + }); +}); + +test.describe('Feed - Pagination', () => { + test('should show load more button when there are more posts', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Check if load more button is visible (only if there are enough posts) + const postCount = await feedPage.getPostCount(); + if (postCount >= 20) { + await expect(feedPage.loadMoreButton).toBeVisible(); + } + }); + + test('should load more posts when clicking load more', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + const initialCount = await feedPage.getPostCount(); + + // Only test if load more is available + const hasLoadMore = await feedPage.loadMoreButton.isVisible().catch(() => false); + if (hasLoadMore) { + await feedPage.loadMore(); + + const newCount = await feedPage.getPostCount(); + expect(newCount).toBeGreaterThanOrEqual(initialCount); + } + }); +}); + +test.describe('Feed - Post Interactions', () => { + test('should like a post', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + const hasPosts = await feedPage.hasPosts(); + if (!hasPosts) { + test.skip(); + return; + } + + // Get first post + const firstPostLocator = await feedPage.getPostByIndex(0); + const postCard = new PostCardComponent(page, firstPostLocator); + + // Like the post + await postCard.like(); + + // Wait for like to register + await page.waitForTimeout(2000); + }); + + test('should bookmark a post', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + const hasPosts = await feedPage.hasPosts(); + if (!hasPosts) { + test.skip(); + return; + } + + // Get first post + const firstPostLocator = await feedPage.getPostByIndex(0); + const postCard = new PostCardComponent(page, firstPostLocator); + + // Bookmark the post + await postCard.bookmark(); + + // Wait for bookmark to register + await page.waitForTimeout(1000); + }); + + test('should navigate to post detail', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + const hasPosts = await feedPage.hasPosts(); + if (!hasPosts) { + test.skip(); + return; + } + + // Click on first post + await feedPage.clickPost(0); + + // Should navigate to post detail + await expect(page).toHaveURL(/\/post/); + }); +}); + +test.describe('Feed - Non-authenticated', () => { + test('should show login prompt for non-authenticated users', async ({ page }) => { + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + // Should show login prompt + const loginPrompt = page.getByRole('link', { name: /login/i }); + await expect(loginPrompt).toBeVisible({ timeout: 10000 }); + }); + + test('should allow viewing feed without login', async ({ page }) => { + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + // Feed tabs should still be visible + const forYouTab = page.getByRole('button', { name: /for you/i }); + await expect(forYouTab).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/tests/e2e/messages/messages.spec.ts b/tests/e2e/messages/messages.spec.ts new file mode 100644 index 00000000..e5c4b5d8 --- /dev/null +++ b/tests/e2e/messages/messages.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { MessagesPage } from '../../pages/messages.page'; +import { generateTestContent } from '../../fixtures/test-identity'; + +test.describe('Messages - Conversations List', () => { + test('should display messages page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + // Should show messages page elements + const hasNewButton = await messagesPage.newConversationButton.isVisible().catch(() => false); + const hasSearch = await messagesPage.searchInput.isVisible().catch(() => false); + const hasConversations = await messagesPage.conversationsList.isVisible().catch(() => false); + const hasEmptyState = await messagesPage.noConversations.isVisible().catch(() => false); + + // Should have some expected elements + expect(hasNewButton || hasConversations || hasEmptyState).toBe(true); + }); + + test('should show new conversation button', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const hasNewButton = await messagesPage.newConversationButton.isVisible().catch(() => false); + expect(hasNewButton).toBe(true); + }); + + test('should open new conversation modal', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const hasNewButton = await messagesPage.newConversationButton.isVisible().catch(() => false); + if (!hasNewButton) { + test.skip(); + return; + } + + await messagesPage.newConversationButton.click(); + await page.waitForTimeout(500); + + // Modal should be open + const modalVisible = await messagesPage.newConversationModal.isVisible().catch(() => false); + expect(modalVisible).toBe(true); + }); +}); + +test.describe('Messages - Conversation Thread', () => { + test('should open conversation when clicking on it', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const conversationCount = await messagesPage.getConversationCount(); + if (conversationCount === 0) { + test.skip(); + return; + } + + // Open first conversation + await messagesPage.openConversation(0); + + // Message input should be visible + const hasMessageInput = await messagesPage.messageInput.isVisible().catch(() => false); + expect(hasMessageInput).toBe(true); + }); + + test('should show send button in conversation', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const conversationCount = await messagesPage.getConversationCount(); + if (conversationCount === 0) { + test.skip(); + return; + } + + await messagesPage.openConversation(0); + + // Send button should be visible + const hasSendButton = await messagesPage.sendButton.isVisible().catch(() => false); + expect(hasSendButton).toBe(true); + }); + + test('should type message in input', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const conversationCount = await messagesPage.getConversationCount(); + if (conversationCount === 0) { + test.skip(); + return; + } + + await messagesPage.openConversation(0); + + // Type a message + const testMessage = 'Test message #test #automated'; + await messagesPage.messageInput.fill(testMessage); + + // Verify message is in input + const value = await messagesPage.messageInput.inputValue(); + expect(value).toBe(testMessage); + }); +}); + +test.describe('Messages - Search', () => { + test('should search conversations', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const messagesPage = new MessagesPage(page); + + await messagesPage.goto(); + await messagesPage.waitForConversationsLoad(); + + const hasSearch = await messagesPage.searchInput.isVisible().catch(() => false); + if (!hasSearch) { + test.skip(); + return; + } + + await messagesPage.searchConversations('test'); + + // Search should filter results + await page.waitForTimeout(500); + }); +}); diff --git a/tests/e2e/navigation/navigation.spec.ts b/tests/e2e/navigation/navigation.spec.ts new file mode 100644 index 00000000..ae998572 --- /dev/null +++ b/tests/e2e/navigation/navigation.spec.ts @@ -0,0 +1,208 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { BasePage } from '../../pages/base.page'; + +test.describe('Navigation - Sidebar', () => { + test('should display sidebar navigation', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + // Check for sidebar navigation links + const homeLink = page.getByRole('link', { name: /home/i }); + const hasHome = await homeLink.isVisible().catch(() => false); + expect(hasHome).toBe(true); + }); + + test('should navigate to explore from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const exploreLink = page.getByRole('link', { name: /explore|search/i }).first(); + if (await exploreLink.isVisible()) { + await exploreLink.click(); + await expect(page).toHaveURL(/\/explore/); + } + }); + + test('should navigate to profile from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const profileLink = page.getByRole('link', { name: /profile/i }).first(); + if (await profileLink.isVisible()) { + await profileLink.click(); + await expect(page).toHaveURL(/\/profile|\/user/); + } + }); + + test('should navigate to messages from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const messagesLink = page.getByRole('link', { name: /messages/i }).first(); + if (await messagesLink.isVisible()) { + await messagesLink.click(); + await expect(page).toHaveURL(/\/messages/); + } + }); + + test('should navigate to notifications from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const notificationsLink = page.getByRole('link', { name: /notifications/i }).first(); + if (await notificationsLink.isVisible()) { + await notificationsLink.click(); + await expect(page).toHaveURL(/\/notifications/); + } + }); + + test('should navigate to bookmarks from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const bookmarksLink = page.getByRole('link', { name: /bookmarks/i }).first(); + if (await bookmarksLink.isVisible()) { + await bookmarksLink.click(); + await expect(page).toHaveURL(/\/bookmarks/); + } + }); + + test('should navigate to settings from sidebar', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + const settingsLink = page.getByRole('link', { name: /settings/i }).first(); + if (await settingsLink.isVisible()) { + await settingsLink.click(); + await expect(page).toHaveURL(/\/settings/); + } + }); +}); + +test.describe('Navigation - Public Pages', () => { + test('should navigate to home page', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Should show home page or redirect to feed + const hasWelcome = await page.getByText(/welcome|yappr/i).first().isVisible().catch(() => false); + const isFeed = page.url().includes('/feed'); + expect(hasWelcome || isFeed).toBe(true); + }); + + test('should navigate to login page', async ({ page }) => { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + // Should show login form + await expect(page.getByText(/sign in/i)).toBeVisible(); + }); + + test('should navigate to about page', async ({ page }) => { + await page.goto('/about'); + await page.waitForLoadState('networkidle'); + + // Should show about content + const hasContent = await page.getByText(/about|yappr/i).first().isVisible().catch(() => false); + expect(hasContent).toBe(true); + }); + + test('should navigate to terms page', async ({ page }) => { + await page.goto('/terms'); + await page.waitForLoadState('networkidle'); + + // Should show terms content + const hasContent = await page.getByText(/terms/i).first().isVisible().catch(() => false); + expect(hasContent).toBe(true); + }); + + test('should navigate to privacy page', async ({ page }) => { + await page.goto('/privacy'); + await page.waitForLoadState('networkidle'); + + // Should show privacy content + const hasContent = await page.getByText(/privacy/i).first().isVisible().catch(() => false); + expect(hasContent).toBe(true); + }); +}); + +test.describe('Navigation - Post Detail', () => { + test('should navigate to post detail page', async ({ page }) => { + // Go to feed first + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + + // Wait for posts to load + await page.waitForTimeout(3000); + + // Check if there are posts + const posts = page.locator('article'); + const postCount = await posts.count(); + + if (postCount > 0) { + // Click on first post content + const firstPost = posts.first(); + await firstPost.click(); + + // Should navigate to post detail + await page.waitForURL(/\/post/, { timeout: 10000 }); + } + }); + + test('should show back button on post detail', async ({ page }) => { + await page.goto('/feed'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const posts = page.locator('article'); + const postCount = await posts.count(); + + if (postCount > 0) { + await posts.first().click(); + await page.waitForURL(/\/post/, { timeout: 10000 }); + + // Should show back button + const backButton = page.getByRole('button', { name: /back/i }); + const hasBack = await backButton.isVisible().catch(() => false); + expect(typeof hasBack).toBe('boolean'); + } + }); +}); + +test.describe('Navigation - Hashtag Page', () => { + test('should navigate to hashtag page', async ({ page }) => { + await page.goto('/hashtag?tag=test'); + await page.waitForLoadState('networkidle'); + + // Should show hashtag content or empty state + const hasHashtag = await page.getByText(/#test/i).isVisible().catch(() => false); + expect(typeof hasHashtag).toBe('boolean'); + }); +}); + +test.describe('Navigation - User Profile', () => { + test('should navigate to user page with ID', async ({ page }) => { + // Use a test identity ID + await page.goto('/user?id=E46NuyTqWrCj1hnGN7gGdo4qCkwPdcNAN2poEZzYguzw'); + await page.waitForLoadState('networkidle'); + + // Should show profile or loading + await page.waitForTimeout(3000); + const hasContent = await page.locator('main').isVisible().catch(() => false); + expect(hasContent).toBe(true); + }); +}); diff --git a/tests/e2e/notifications/notifications.spec.ts b/tests/e2e/notifications/notifications.spec.ts new file mode 100644 index 00000000..48c6e372 --- /dev/null +++ b/tests/e2e/notifications/notifications.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '../../fixtures/test-fixtures'; + +test.describe('Notifications - Display', () => { + test('should display notifications page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + // Should show notifications heading or content + const hasNotificationsText = await page.getByText(/notifications/i).first().isVisible().catch(() => false); + expect(hasNotificationsText).toBe(true); + }); + + test('should show filter tabs', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + // Check for filter buttons + const allTab = page.getByRole('button', { name: /all/i }); + const hasAllTab = await allTab.isVisible().catch(() => false); + expect(typeof hasAllTab).toBe('boolean'); + }); + + test('should show empty state when no notifications', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + // Should either have notifications or show empty state + const hasEmptyState = await page.getByText(/no notifications|empty/i).isVisible().catch(() => false); + const hasNotifications = await page.locator('[data-notification], li, article').first().isVisible().catch(() => false); + + expect(hasEmptyState || hasNotifications).toBe(true); + }); + + test('should show settings link', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + // Check for settings button + const settingsButton = page.getByRole('link', { name: /settings/i }); + const hasSettings = await settingsButton.isVisible().catch(() => false); + expect(typeof hasSettings).toBe('boolean'); + }); +}); + +test.describe('Notifications - Filters', () => { + test('should filter by likes', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + const likesTab = page.getByRole('button', { name: /likes/i }); + const hasLikesTab = await likesTab.isVisible().catch(() => false); + + if (hasLikesTab) { + await likesTab.click(); + await page.waitForTimeout(500); + } + }); + + test('should filter by follows', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + const followsTab = page.getByRole('button', { name: /follows/i }); + const hasFollowsTab = await followsTab.isVisible().catch(() => false); + + if (hasFollowsTab) { + await followsTab.click(); + await page.waitForTimeout(500); + } + }); + + test('should filter by replies', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + const repliesTab = page.getByRole('button', { name: /replies/i }); + const hasRepliesTab = await repliesTab.isVisible().catch(() => false); + + if (hasRepliesTab) { + await repliesTab.click(); + await page.waitForTimeout(500); + } + }); +}); + +test.describe('Notifications - Actions', () => { + test('should mark all as read', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/notifications'); + await page.waitForLoadState('networkidle'); + + const markAllReadButton = page.getByRole('button', { name: /mark all|read all/i }); + const hasMarkAllRead = await markAllReadButton.isVisible().catch(() => false); + expect(typeof hasMarkAllRead).toBe('boolean'); + }); +}); diff --git a/tests/e2e/posts/create-post.spec.ts b/tests/e2e/posts/create-post.spec.ts new file mode 100644 index 00000000..801f5643 --- /dev/null +++ b/tests/e2e/posts/create-post.spec.ts @@ -0,0 +1,192 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { FeedPage } from '../../pages/feed.page'; +import { ComposeModalComponent } from '../../pages/components/compose-modal.component'; +import { generateTestContent } from '../../fixtures/test-identity'; + +test.describe('Post Creation', () => { + test('should open compose modal from feed', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Open compose modal + await feedPage.openCompose(); + + // Verify modal is open + await expect(composeModal.modal).toBeVisible(); + await expect(composeModal.textarea).toBeVisible(); + await expect(composeModal.postButton).toBeVisible(); + }); + + test('should show character counter', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Type some content + await composeModal.fillContent('Hello world #test'); + + // Check character counter + await page.waitForTimeout(300); + const charCount = await composeModal.getCharacterCount(); + expect(charCount).toBeGreaterThan(0); + }); + + test('should disable post button when empty', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Post button should be disabled with empty content + const isEnabled = await composeModal.isPostEnabled(); + expect(isEnabled).toBe(false); + }); + + test('should enable post button with content', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Add content + await composeModal.fillContent('Test post #test #automated'); + + // Post button should be enabled + const isEnabled = await composeModal.isPostEnabled(); + expect(isEnabled).toBe(true); + }); + + test('should close compose modal', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Close modal + await composeModal.close(); + + // Modal should be closed + const isOpen = await composeModal.isOpen(); + expect(isOpen).toBe(false); + }); + + test('should create a post successfully', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Create unique post content + const postContent = generateTestContent('E2E Test Post'); + + // Create post + await composeModal.createPost(postContent); + + // Wait for post to be created (modal should close) + await page.waitForTimeout(5000); + + // Refresh feed to see new post + await feedPage.refreshFeed(); + + // New post should appear in feed (eventually) + await page.waitForTimeout(3000); + const postVisible = await page.getByText(postContent.substring(0, 20)).isVisible().catch(() => false); + // Note: Due to Dash Platform latency, post may not appear immediately + // This is expected behavior + }); + + test('should enforce 500 character limit', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Try to enter more than 500 characters + const longContent = 'A'.repeat(510) + ' #test #automated'; + await composeModal.fillContent(longContent); + + // Content should be limited or button disabled + const content = await composeModal.getContent(); + const charCount = await composeModal.getCharacterCount(); + + // Either content is truncated or we're over limit + expect(content.length <= 500 || charCount > 500).toBe(true); + }); + + test('should include hashtags in post', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + await feedPage.openCompose(); + + // Create post with hashtags + const postContent = 'Testing hashtags #test #automated #yappr'; + await composeModal.fillContent(postContent); + + // Verify content contains hashtags + const content = await composeModal.getContent(); + expect(content).toContain('#test'); + expect(content).toContain('#automated'); + }); +}); + +test.describe('Post - Reply', () => { + test('should show reply indicator when replying to post', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const feedPage = new FeedPage(page); + const composeModal = new ComposeModalComponent(page); + + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + // Wait for posts to load + await page.waitForTimeout(3000); + + // Check if there are any posts to reply to + const hasPosts = await feedPage.hasPosts(); + if (!hasPosts) { + test.skip(); + return; + } + + // Click reply on first post + const firstPost = await feedPage.getPostByIndex(0); + const replyButton = firstPost.locator('button').first(); + await replyButton.click(); + + // Wait for compose modal with reply indicator + await page.waitForTimeout(1000); + + // Check if we're in reply mode (compose modal or post detail with reply) + const modalOpen = await composeModal.isOpen(); + const onPostPage = page.url().includes('/post'); + + expect(modalOpen || onPostPage).toBe(true); + }); +}); diff --git a/tests/e2e/profile/profile.spec.ts b/tests/e2e/profile/profile.spec.ts new file mode 100644 index 00000000..d3b40926 --- /dev/null +++ b/tests/e2e/profile/profile.spec.ts @@ -0,0 +1,188 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { ProfilePage } from '../../pages/profile.page'; +import { ProfileCreatePage } from '../../pages/profile-create.page'; +import { TEST_IDENTITY, getHighAuthKey, generateTestDisplayName } from '../../fixtures/test-identity'; + +test.describe('Profile - View', () => { + test('should display own profile correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Verify basic profile elements are visible + await expect(profilePage.displayName).toBeVisible({ timeout: 30000 }); + await expect(profilePage.avatar).toBeVisible(); + }); + + test('should display follower and following counts', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Verify counts are displayed + await expect(profilePage.followersCount).toBeVisible({ timeout: 30000 }); + await expect(profilePage.followingCount).toBeVisible(); + }); + + test('should navigate to followers list', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Click followers + await profilePage.viewFollowers(); + + // Verify navigation + await expect(page).toHaveURL(/\/followers/); + }); + + test('should navigate to following list', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Click following + await profilePage.viewFollowing(); + + // Verify navigation + await expect(page).toHaveURL(/\/following/); + }); + + test('should display user posts on profile', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Wait for posts to load (or empty state) + await page.waitForTimeout(3000); + + // Either posts exist or empty state is shown + const hasPosts = await profilePage.posts.first().isVisible().catch(() => false); + const hasEmptyState = await profilePage.noPosts.isVisible().catch(() => false); + + expect(hasPosts || hasEmptyState).toBe(true); + }); + + test('should view another user profile', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + // Use a known identity (could be any user on the platform) + // We'll use our test identity viewing mode + await page.goto(`/user?id=${TEST_IDENTITY.identityId}`); + await profilePage.waitForProfileLoad(); + + // Verify profile loads + await expect(profilePage.displayName).toBeVisible({ timeout: 30000 }); + }); +}); + +test.describe('Profile - Edit', () => { + test('should navigate to profile edit page', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Check if edit button exists + const hasEditButton = await profilePage.editProfileButton.isVisible().catch(() => false); + if (hasEditButton) { + await profilePage.editProfile(); + await expect(page).toHaveURL(/\/profile\/create/); + } + }); + + test('should display profile create/edit form', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const createPage = new ProfileCreatePage(page); + + await createPage.goto(); + + // Verify form fields are visible + await expect(createPage.displayNameInput).toBeVisible({ timeout: 30000 }); + await expect(createPage.bioInput).toBeVisible(); + }); + + test('should validate display name length', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const createPage = new ProfileCreatePage(page); + + await createPage.goto(); + + // Enter a very long name (over 50 chars) + const longName = 'A'.repeat(60); + await createPage.fillDisplayName(longName); + + // The input should be limited or show error + const value = await createPage.displayNameInput.inputValue(); + expect(value.length).toBeLessThanOrEqual(50); + }); + + test('should show bio character counter', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const createPage = new ProfileCreatePage(page); + + await createPage.goto(); + + // Enter some bio text + await createPage.fillBio('This is my test bio #test'); + + // Wait for counter to update + await page.waitForTimeout(300); + + // Counter should be visible and show count + const counterVisible = await createPage.bioCounter.isVisible().catch(() => false); + if (counterVisible) { + const count = await createPage.getBioCharCount(); + expect(count).toBeGreaterThan(0); + } + }); + + test('should allow editing profile fields', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const createPage = new ProfileCreatePage(page); + + await createPage.goto(); + + // Fill in profile fields + const testName = generateTestDisplayName(); + await createPage.fillDisplayName(testName); + await createPage.fillBio('Test bio for E2E testing #test #automated'); + await createPage.fillPronouns('they/them'); + await createPage.fillLocation('Test City'); + + // Verify values are set + const displayName = await createPage.displayNameInput.inputValue(); + expect(displayName).toBe(testName); + }); +}); + +test.describe('Profile - Social Actions', () => { + test('should share profile link', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Click share button if visible + const hasShareButton = await profilePage.shareButton.isVisible().catch(() => false); + if (hasShareButton) { + await profilePage.shareProfile(); + + // Should copy to clipboard (toast notification) + await page.waitForTimeout(500); + } + }); +}); diff --git a/tests/e2e/settings/settings.spec.ts b/tests/e2e/settings/settings.spec.ts new file mode 100644 index 00000000..1995cf11 --- /dev/null +++ b/tests/e2e/settings/settings.spec.ts @@ -0,0 +1,214 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { SettingsPage } from '../../pages/settings.page'; + +test.describe('Settings - Navigation', () => { + test('should display settings page correctly', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + // Should show settings sections + const hasAccount = await settingsPage.accountSection.isVisible().catch(() => false); + const hasAppearance = await settingsPage.appearanceSection.isVisible().catch(() => false); + + expect(hasAccount || hasAppearance).toBe(true); + }); + + test('should navigate to account section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAccount = await settingsPage.accountSection.isVisible().catch(() => false); + if (hasAccount) { + await settingsPage.goToAccount(); + + // Should show account info + const hasIdentity = await settingsPage.identityId.isVisible().catch(() => false); + const hasLogout = await settingsPage.logoutButton.isVisible().catch(() => false); + expect(hasIdentity || hasLogout).toBe(true); + } + }); + + test('should navigate to notifications section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasNotifications = await settingsPage.notificationsSection.isVisible().catch(() => false); + if (hasNotifications) { + await settingsPage.goToNotifications(); + + // Should show notification toggles + await page.waitForTimeout(500); + } + }); + + test('should navigate to privacy section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasPrivacy = await settingsPage.privacySection.isVisible().catch(() => false); + if (hasPrivacy) { + await settingsPage.goToPrivacy(); + + // Should show privacy settings + await page.waitForTimeout(500); + } + }); + + test('should navigate to appearance section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAppearance = await settingsPage.appearanceSection.isVisible().catch(() => false); + if (hasAppearance) { + await settingsPage.goToAppearance(); + + // Should show theme options + await page.waitForTimeout(500); + } + }); + + test('should navigate to about section', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAbout = await settingsPage.aboutSection.isVisible().catch(() => false); + if (hasAbout) { + await settingsPage.goToAbout(); + + // Should show about info + await page.waitForTimeout(500); + } + }); +}); + +test.describe('Settings - Theme', () => { + test('should display theme options', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAppearance = await settingsPage.appearanceSection.isVisible().catch(() => false); + if (hasAppearance) { + await settingsPage.goToAppearance(); + + // Check for theme options + const hasLight = await settingsPage.lightThemeOption.isVisible().catch(() => false); + const hasDark = await settingsPage.darkThemeOption.isVisible().catch(() => false); + const hasSystem = await settingsPage.systemThemeOption.isVisible().catch(() => false); + + expect(hasLight || hasDark || hasSystem).toBe(true); + } + }); + + test('should switch to dark theme', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAppearance = await settingsPage.appearanceSection.isVisible().catch(() => false); + if (hasAppearance) { + await settingsPage.goToAppearance(); + + const hasDark = await settingsPage.darkThemeOption.isVisible().catch(() => false); + if (hasDark) { + await settingsPage.setTheme('dark'); + + // Page should have dark class + await page.waitForTimeout(500); + const htmlClass = await page.locator('html').getAttribute('class'); + const hasDarkClass = htmlClass?.includes('dark') || false; + expect(hasDarkClass).toBe(true); + } + } + }); + + test('should switch to light theme', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAppearance = await settingsPage.appearanceSection.isVisible().catch(() => false); + if (hasAppearance) { + await settingsPage.goToAppearance(); + + const hasLight = await settingsPage.lightThemeOption.isVisible().catch(() => false); + if (hasLight) { + await settingsPage.setTheme('light'); + + // Page should have light theme + await page.waitForTimeout(500); + const htmlClass = await page.locator('html').getAttribute('class'); + const hasDarkClass = htmlClass?.includes('dark') || false; + expect(hasDarkClass).toBe(false); + } + } + }); +}); + +test.describe('Settings - Account', () => { + test('should display identity ID', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAccount = await settingsPage.accountSection.isVisible().catch(() => false); + if (hasAccount) { + await settingsPage.goToAccount(); + + // Should display identity ID somewhere + const hasIdentity = await settingsPage.identityId.isVisible().catch(() => false); + expect(typeof hasIdentity).toBe('boolean'); + } + }); + + test('should display logout button', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasAccount = await settingsPage.accountSection.isVisible().catch(() => false); + if (hasAccount) { + await settingsPage.goToAccount(); + + // Logout button should be visible + const hasLogout = await settingsPage.logoutButton.isVisible().catch(() => false); + expect(hasLogout).toBe(true); + } + }); +}); + +test.describe('Settings - Notifications', () => { + test('should display notification toggles', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const settingsPage = new SettingsPage(page); + + await settingsPage.goto(); + + const hasNotifications = await settingsPage.notificationsSection.isVisible().catch(() => false); + if (hasNotifications) { + await settingsPage.goToNotifications(); + + // Should show some notification toggles + const switchElements = page.getByRole('switch'); + const switchCount = await switchElements.count(); + expect(switchCount).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/tests/e2e/social/follow.spec.ts b/tests/e2e/social/follow.spec.ts new file mode 100644 index 00000000..f293783c --- /dev/null +++ b/tests/e2e/social/follow.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '../../fixtures/test-fixtures'; +import { ProfilePage } from '../../pages/profile.page'; +import { FeedPage } from '../../pages/feed.page'; +import { TEST_IDENTITY } from '../../fixtures/test-identity'; + +test.describe('Social - Follow/Unfollow', () => { + test('should display follow button on other user profiles', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + // Navigate to feed and find a post from another user + const feedPage = new FeedPage(page); + await feedPage.goto(); + await feedPage.waitForFeedLoad(); + + const hasPosts = await feedPage.hasPosts(); + if (!hasPosts) { + test.skip(); + return; + } + + // Click on author avatar to go to their profile + const firstPost = await feedPage.getPostByIndex(0); + const authorAvatar = firstPost.locator('img, [data-avatar]').first(); + await authorAvatar.click(); + + // Wait for profile to load + await page.waitForURL(/\/user|\/profile/, { timeout: 10000 }); + await profilePage.waitForProfileLoad(); + + // Check if this is not our own profile + const isOwnProfile = page.url().includes(TEST_IDENTITY.identityId); + if (!isOwnProfile) { + // Should show follow/following button + const hasFollowButton = await profilePage.followButton.isVisible().catch(() => false); + const hasUnfollowButton = await profilePage.unfollowButton.isVisible().catch(() => false); + expect(hasFollowButton || hasUnfollowButton).toBe(true); + } + }); + + test('should not show follow button on own profile', async ({ authenticatedPage }) => { + const page = authenticatedPage; + const profilePage = new ProfilePage(page); + + await profilePage.goto(); + await profilePage.waitForProfileLoad(); + + // Should not show follow button on own profile + const hasFollowButton = await profilePage.followButton.isVisible().catch(() => false); + expect(hasFollowButton).toBe(false); + }); +}); + +test.describe('Social - Followers/Following Lists', () => { + test('should display followers list', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/followers'); + await page.waitForLoadState('networkidle'); + + // Should show followers page + await expect(page.getByText(/followers/i)).toBeVisible(); + }); + + test('should display following list', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/following'); + await page.waitForLoadState('networkidle'); + + // Should show following page + await expect(page.getByText(/following/i)).toBeVisible(); + }); + + test('should allow searching following list', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/following'); + await page.waitForLoadState('networkidle'); + + // Check for search input + const searchInput = page.getByPlaceholder(/search/i); + const hasSearch = await searchInput.isVisible().catch(() => false); + + if (hasSearch) { + await searchInput.fill('test'); + await page.waitForTimeout(500); + } + }); + + test('should show refresh button on followers page', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/followers'); + await page.waitForLoadState('networkidle'); + + // Should have refresh functionality + const refreshButton = page.locator('button').filter({ has: page.locator('svg') }).first(); + await expect(refreshButton).toBeVisible(); + }); +}); + +test.describe('Social - Block', () => { + test('should access blocked users from settings', async ({ authenticatedPage }) => { + const page = authenticatedPage; + + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + + // Navigate to privacy section if available + const privacySection = page.getByRole('button', { name: /privacy|security/i }); + if (await privacySection.isVisible().catch(() => false)) { + await privacySection.click(); + await page.waitForTimeout(300); + + // Look for blocked users option + const blockedUsersButton = page.getByRole('button', { name: /blocked|view blocked/i }); + const hasBlockedSection = await blockedUsersButton.isVisible().catch(() => false); + + if (hasBlockedSection) { + await blockedUsersButton.click(); + await page.waitForTimeout(500); + } + } + }); +}); diff --git a/tests/fixtures/test-fixtures.ts b/tests/fixtures/test-fixtures.ts new file mode 100644 index 00000000..40c6e7a8 --- /dev/null +++ b/tests/fixtures/test-fixtures.ts @@ -0,0 +1,84 @@ +import { test as base, Page, expect } from '@playwright/test'; +import { TEST_IDENTITY, getHighAuthKey } from './test-identity'; + +// Custom fixture types +type TestFixtures = { + authenticatedPage: Page; + loggedInPage: Page; +}; + +// Extend Playwright's base test with custom fixtures +export const test = base.extend({ + // A page that's been authenticated with the test identity + authenticatedPage: async ({ page }, use) => { + await loginWithTestIdentity(page); + await use(page); + }, + + // Alias for authenticatedPage + loggedInPage: async ({ authenticatedPage }, use) => { + await use(authenticatedPage); + }, +}); + +// Re-export expect +export { expect }; + +// Helper function to login with test identity +export async function loginWithTestIdentity(page: Page): Promise { + await page.goto('/'); + + // Wait for the page to load + await page.waitForLoadState('networkidle'); + + // Check if we're already logged in + const isLoggedIn = await page.locator('[data-testid="compose-button"], button:has-text("What\'s happening")').first().isVisible().catch(() => false); + + if (isLoggedIn) { + return; // Already logged in + } + + // Look for login button or login form + const loginButton = page.getByRole('button', { name: /connect|login|sign in/i }); + const hasLoginButton = await loginButton.first().isVisible().catch(() => false); + + if (hasLoginButton) { + await loginButton.first().click(); + await page.waitForTimeout(500); + } + + // Fill in identity ID + const identityInput = page.getByPlaceholder(/identity|username/i).first(); + if (await identityInput.isVisible().catch(() => false)) { + await identityInput.fill(TEST_IDENTITY.identityId); + await page.waitForTimeout(500); + } + + // Look for private key input and fill it + const privateKeyInput = page.getByPlaceholder(/private key|wif/i).first(); + if (await privateKeyInput.isVisible().catch(() => false)) { + await privateKeyInput.fill(getHighAuthKey()); + } + + // Click connect/login button + const connectButton = page.getByRole('button', { name: /connect|login|continue|sign in/i }); + if (await connectButton.first().isVisible().catch(() => false)) { + await connectButton.first().click(); + } + + // Wait for redirect to feed or for feed to load + await page.waitForTimeout(2000); + + // Verify we're logged in by checking for compose button or feed + await expect(page.locator('body')).toBeVisible(); +} + +// Helper to wait for Dash Platform operations (which can be slow) +export async function waitForPlatformOperation(page: Page, timeout: number = 30000): Promise { + await page.waitForLoadState('networkidle', { timeout }); +} + +// Helper to generate unique test ID +export function generateTestId(): string { + return `test_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; +} diff --git a/tests/fixtures/test-identity.ts b/tests/fixtures/test-identity.ts new file mode 100644 index 00000000..456629e4 --- /dev/null +++ b/tests/fixtures/test-identity.ts @@ -0,0 +1,55 @@ +// Test identity for E2E tests - this is a real identity on Dash Platform +// All public interactions will be marked with #test and #automated hashtags + +export const TEST_IDENTITY = { + identityId: 'E46NuyTqWrCj1hnGN7gGdo4qCkwPdcNAN2poEZzYguzw', + assetLockKey: 'cUoUCHGefRwpqxkFEoXkQjBa6BVNAtSEDNTmng73RvhtbkJrf7sA', + identityKeys: [ + { + name: 'Master (Authentication)', + id: 0, + purpose: 'AUTHENTICATION', + securityLevel: 'MASTER', + privateKeyWif: 'cTnfEvnQMjX7GM6RnrArf9N3LuU4roqeoUPLxC6H9NLTmZma1EsM', + }, + { + name: 'High Auth', + id: 1, + purpose: 'AUTHENTICATION', + securityLevel: 'HIGH', + privateKeyWif: 'cSgRNAvUchuWYNLm45xnLxWCrGyyysB6bcHeb8vgQHfivvGEJgMi', + }, + { + name: 'Critical Auth', + id: 2, + purpose: 'AUTHENTICATION', + securityLevel: 'CRITICAL', + privateKeyWif: 'cUzfneiG8JvRS4gxFdTLCJvSM8YkHtdyvqS2VLfsH4bzQzUMRtwq', + }, + { + name: 'Transfer', + id: 3, + purpose: 'TRANSFER', + securityLevel: 'CRITICAL', + privateKeyWif: 'cPivVm5bBneSBv9AvQrk23DC6KaGsV7GE3XaYDpYNpYcH2eJTTUD', + }, + ], +}; + +// Helper to get the High Auth key (used for most operations) +export function getHighAuthKey(): string { + return TEST_IDENTITY.identityKeys[1].privateKeyWif; +} + +// Helper to generate unique test content with required hashtags +export function generateTestContent(prefix: string = 'Test'): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix} ${timestamp}-${random} #test #automated`; +} + +// Helper to generate unique display name for profiles +export function generateTestDisplayName(): string { + const timestamp = Date.now(); + return `Test User ${timestamp}`; +} diff --git a/tests/pages/base.page.ts b/tests/pages/base.page.ts new file mode 100644 index 00000000..f487a715 --- /dev/null +++ b/tests/pages/base.page.ts @@ -0,0 +1,94 @@ +import { Page, Locator, expect } from '@playwright/test'; + +export abstract class BasePage { + constructor(protected page: Page) {} + + // Common navigation elements + get navigation() { + return this.page.locator('nav, [role="navigation"]').first(); + } + + get homeLink() { + return this.page.getByRole('link', { name: /home/i }).first(); + } + + get exploreLink() { + return this.page.getByRole('link', { name: /explore|search/i }).first(); + } + + get profileLink() { + return this.page.getByRole('link', { name: /profile/i }).first(); + } + + get messagesLink() { + return this.page.getByRole('link', { name: /messages/i }).first(); + } + + get notificationsLink() { + return this.page.getByRole('link', { name: /notifications/i }).first(); + } + + get bookmarksLink() { + return this.page.getByRole('link', { name: /bookmarks/i }).first(); + } + + get settingsLink() { + return this.page.getByRole('link', { name: /settings/i }).first(); + } + + // Common actions + async waitForPageLoad(timeout: number = 30000) { + await this.page.waitForLoadState('domcontentloaded', { timeout }); + } + + async waitForNetworkIdle(timeout: number = 30000) { + await this.page.waitForLoadState('networkidle', { timeout }); + } + + async navigateToHome() { + await this.homeLink.click(); + await this.waitForPageLoad(); + } + + async navigateToExplore() { + await this.exploreLink.click(); + await this.waitForPageLoad(); + } + + async navigateToProfile() { + await this.profileLink.click(); + await this.waitForPageLoad(); + } + + async navigateToMessages() { + await this.messagesLink.click(); + await this.waitForPageLoad(); + } + + async navigateToNotifications() { + await this.notificationsLink.click(); + await this.waitForPageLoad(); + } + + async navigateToBookmarks() { + await this.bookmarksLink.click(); + await this.waitForPageLoad(); + } + + async navigateToSettings() { + await this.settingsLink.click(); + await this.waitForPageLoad(); + } + + // Toast notifications + async waitForToast(text?: string | RegExp) { + const toast = this.page.locator('[role="status"], .toast, [class*="toast"]').first(); + await expect(toast).toBeVisible({ timeout: 10000 }); + if (text) { + await expect(toast).toContainText(text); + } + } + + // Abstract method for page-specific navigation + abstract goto(): Promise; +} diff --git a/tests/pages/components/compose-modal.component.ts b/tests/pages/components/compose-modal.component.ts new file mode 100644 index 00000000..196e3eb3 --- /dev/null +++ b/tests/pages/components/compose-modal.component.ts @@ -0,0 +1,97 @@ +import { Page, Locator, expect } from '@playwright/test'; + +export class ComposeModalComponent { + constructor(private page: Page) {} + + // Modal container + get modal() { + return this.page.locator('[role="dialog"]'); + } + + // Close button + get closeButton() { + return this.modal.locator('button').filter({ has: this.page.locator('svg') }).first(); + } + + // Textarea for post content + get textarea() { + return this.modal.locator('textarea'); + } + + // Character counter + get characterCounter() { + return this.modal.locator('span').filter({ hasText: /\/500/ }); + } + + // Post button + get postButton() { + return this.modal.getByRole('button', { name: /post/i }); + } + + // Reply indicator + get replyIndicator() { + return this.modal.getByText(/replying to/i); + } + + // Wait for modal to open + async waitForOpen(timeout: number = 5000) { + await this.modal.waitFor({ state: 'visible', timeout }); + } + + // Wait for modal to close + async waitForClose(timeout: number = 10000) { + await this.modal.waitFor({ state: 'hidden', timeout }); + } + + // Check if modal is open + async isOpen(): Promise { + return await this.modal.isVisible(); + } + + // Close the modal + async close() { + await this.closeButton.click(); + await this.waitForClose(); + } + + // Fill post content + async fillContent(content: string) { + await this.textarea.fill(content); + } + + // Get current content + async getContent(): Promise { + return await this.textarea.inputValue(); + } + + // Get character count + async getCharacterCount(): Promise { + const text = await this.characterCounter.textContent(); + const match = text?.match(/(\d+)\/500/); + return match ? parseInt(match[1]) : 0; + } + + // Check if post button is enabled + async isPostEnabled(): Promise { + return !(await this.postButton.isDisabled()); + } + + // Submit the post + async submitPost() { + await this.postButton.click(); + } + + // Create a post (full flow) + async createPost(content: string, waitForClose: boolean = true) { + await this.fillContent(content); + await this.submitPost(); + if (waitForClose) { + await this.waitForClose(); + } + } + + // Check if this is a reply + async isReply(): Promise { + return await this.replyIndicator.isVisible().catch(() => false); + } +} diff --git a/tests/pages/components/post-card.component.ts b/tests/pages/components/post-card.component.ts new file mode 100644 index 00000000..90462620 --- /dev/null +++ b/tests/pages/components/post-card.component.ts @@ -0,0 +1,139 @@ +import { Page, Locator, expect } from '@playwright/test'; + +export class PostCardComponent { + constructor( + private page: Page, + private container: Locator + ) {} + + // Post content + get content() { + return this.container.locator('p, [data-post-content]').first(); + } + + // Author info + get authorName() { + return this.container.locator('span, a').filter({ hasText: /\w+/ }).first(); + } + + get authorUsername() { + return this.container.locator('span, a').filter({ hasText: /@|user_/ }).first(); + } + + get authorAvatar() { + return this.container.locator('img[alt*="avatar"], [data-avatar]').first(); + } + + // Timestamp + get timestamp() { + return this.container.locator('time, span').filter({ hasText: /ago|just now|yesterday/i }).first(); + } + + // Interaction buttons + get replyButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(0); + } + + get repostButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(1); + } + + get likeButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(2); + } + + get tipButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(3); + } + + get bookmarkButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(4); + } + + get shareButton() { + return this.container.locator('button').filter({ has: this.page.locator('svg') }).nth(5); + } + + // More options menu + get moreOptionsButton() { + return this.container.locator('button').filter({ has: this.page.locator('[class*="Ellipsis"]') }); + } + + // Interaction counts + get likeCount() { + return this.likeButton.locator('span').first(); + } + + get repostCount() { + return this.repostButton.locator('span').first(); + } + + get replyCount() { + return this.replyButton.locator('span').first(); + } + + // Actions + async like() { + await this.likeButton.click(); + await this.page.waitForTimeout(1000); + } + + async repost() { + await this.repostButton.click(); + await this.page.waitForTimeout(1000); + } + + async reply() { + await this.replyButton.click(); + await this.page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + } + + async bookmark() { + await this.bookmarkButton.click(); + await this.page.waitForTimeout(500); + } + + async share() { + await this.shareButton.click(); + await this.page.waitForTimeout(500); + } + + async tip() { + await this.tipButton.click(); + await this.page.waitForTimeout(500); + } + + // Navigate to post detail + async viewDetail() { + await this.content.click(); + await this.page.waitForURL(/\/post/); + } + + // Navigate to author profile + async viewAuthorProfile() { + await this.authorAvatar.click(); + await this.page.waitForURL(/\/user|\/profile/); + } + + // Get post text content + async getContent(): Promise { + return await this.content.textContent() || ''; + } + + // Get author display name + async getAuthorName(): Promise { + return await this.authorName.textContent() || ''; + } + + // Check if post is liked + async isLiked(): Promise { + const fill = await this.likeButton.locator('svg').getAttribute('fill'); + return fill === 'currentColor' || fill?.includes('red') || false; + } + + // Check if post is bookmarked + async isBookmarked(): Promise { + const fill = await this.bookmarkButton.locator('svg').getAttribute('fill'); + return fill === 'currentColor' || false; + } +} diff --git a/tests/pages/explore.page.ts b/tests/pages/explore.page.ts new file mode 100644 index 00000000..93e08c54 --- /dev/null +++ b/tests/pages/explore.page.ts @@ -0,0 +1,79 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class ExplorePage extends BasePage { + // Search input + get searchInput() { + return this.page.getByPlaceholder(/search/i); + } + + // Back button (when search is focused) + get searchBackButton() { + return this.page.getByRole('button', { name: /back/i }); + } + + // Search results + get searchResults() { + return this.page.locator('article, [data-post-id]'); + } + + get noResultsMessage() { + return this.page.getByText(/no results|nothing found/i); + } + + // Trending hashtags section + get trendingSection() { + return this.page.locator('section, div').filter({ hasText: /trending/i }); + } + + get trendingHashtags() { + return this.page.locator('a, button').filter({ hasText: /^#/ }); + } + + // Loading indicator + get loadingIndicator() { + return this.page.getByText(/loading|searching/i); + } + + async goto() { + await this.page.goto('/explore'); + await this.waitForPageLoad(); + } + + // Search for content + async search(query: string) { + await this.searchInput.fill(query); + // Wait for debounce + await this.page.waitForTimeout(400); + // Wait for results + await this.page.waitForLoadState('networkidle'); + } + + // Clear search + async clearSearch() { + await this.searchInput.clear(); + await this.page.waitForTimeout(200); + } + + // Click on a trending hashtag + async clickHashtag(index: number = 0) { + await this.trendingHashtags.nth(index).click(); + await this.page.waitForURL(/\/hashtag/); + } + + // Get search results count + async getSearchResultsCount(): Promise { + await this.page.waitForTimeout(500); + return await this.searchResults.count(); + } + + // Get trending hashtags count + async getTrendingHashtagsCount(): Promise { + return await this.trendingHashtags.count(); + } + + // Check if search is focused + async isSearchFocused(): Promise { + return await this.searchInput.evaluate((el) => document.activeElement === el); + } +} diff --git a/tests/pages/feed.page.ts b/tests/pages/feed.page.ts new file mode 100644 index 00000000..f08cbb25 --- /dev/null +++ b/tests/pages/feed.page.ts @@ -0,0 +1,151 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class FeedPage extends BasePage { + // Header elements + get homeTitle() { + return this.page.locator('h1').filter({ hasText: 'Home' }); + } + + get refreshButton() { + return this.page.locator('button').filter({ has: this.page.locator('svg[class*="ArrowPath"]') }); + } + + // Feed tabs + get forYouTab() { + return this.page.getByRole('button', { name: /for you/i }); + } + + get followingTab() { + return this.page.getByRole('button', { name: /following/i }); + } + + // Compose area + get composeButton() { + return this.page.getByRole('button', { name: /what's happening/i }); + } + + get userAvatar() { + return this.page.locator('img[alt*="avatar"]').first(); + } + + // Posts list + get postsList() { + return this.page.locator('main > div'); + } + + get posts() { + return this.page.locator('article, [data-post-id]'); + } + + // Loading states + get loadingIndicator() { + return this.page.getByText(/connecting|loading/i).first(); + } + + get loadMoreButton() { + return this.page.getByRole('button', { name: /load more/i }); + } + + // Empty states + get emptyState() { + return this.page.getByText(/no posts yet|feed is empty/i).first(); + } + + // Migration banner + get migrationBanner() { + return this.page.getByText(/migrate your profile/i); + } + + get migrateButton() { + return this.page.getByRole('button', { name: /migrate/i }); + } + + // Login prompt for non-authenticated users + get loginPrompt() { + return this.page.getByRole('link', { name: /login to share/i }); + } + + async goto() { + await this.page.goto('/feed'); + await this.waitForPageLoad(); + } + + // Wait for feed to load + async waitForFeedLoad(timeout: number = 60000) { + // Wait for loading state to disappear + await this.page.waitForSelector('text=/connecting|loading/i', { state: 'hidden', timeout }).catch(() => {}); + // Small delay for posts to render + await this.page.waitForTimeout(1000); + } + + // Switch to For You tab + async switchToForYou() { + await this.forYouTab.click(); + await this.waitForFeedLoad(); + } + + // Switch to Following tab + async switchToFollowing() { + await this.followingTab.click(); + await this.waitForFeedLoad(); + } + + // Open compose modal + async openCompose() { + await this.composeButton.click(); + await this.page.waitForSelector('[role="dialog"]', { timeout: 5000 }); + } + + // Refresh feed + async refreshFeed() { + await this.refreshButton.click(); + await this.waitForFeedLoad(); + } + + // Get post count + async getPostCount(): Promise { + // Wait a moment for posts to render + await this.page.waitForTimeout(500); + return await this.posts.count(); + } + + // Check if feed has posts + async hasPosts(): Promise { + const count = await this.getPostCount(); + return count > 0; + } + + // Load more posts + async loadMore() { + const button = this.loadMoreButton; + if (await button.isVisible()) { + await button.click(); + await this.page.waitForTimeout(2000); + } + } + + // Get post by index + async getPostByIndex(index: number): Promise { + return this.posts.nth(index); + } + + // Click on a post to view details + async clickPost(index: number = 0) { + const post = await this.getPostByIndex(index); + await post.click(); + await this.page.waitForURL(/\/post/); + } + + // Check if active tab is For You + async isForYouActive(): Promise { + const classes = await this.forYouTab.getAttribute('class'); + return classes?.includes('text-gray-900') || classes?.includes('text-white') || false; + } + + // Check if active tab is Following + async isFollowingActive(): Promise { + const classes = await this.followingTab.getAttribute('class'); + return classes?.includes('text-gray-900') || classes?.includes('text-white') || false; + } +} diff --git a/tests/pages/login.page.ts b/tests/pages/login.page.ts new file mode 100644 index 00000000..588f2f5e --- /dev/null +++ b/tests/pages/login.page.ts @@ -0,0 +1,153 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class LoginPage extends BasePage { + // Identity input + get identityInput() { + return this.page.locator('#identityInput'); + } + + // Credential input (password or private key) + get credentialInput() { + return this.page.locator('#credential'); + } + + // Show/hide credential toggle + get showCredentialToggle() { + return this.page.locator('button').filter({ has: this.page.locator('svg') }).first(); + } + + // Remember me toggle + get rememberMeToggle() { + return this.page.getByRole('switch'); + } + + // Sign in button + get signInButton() { + return this.page.getByRole('button', { name: /sign in/i }); + } + + // Loading indicators + get identityLoadingSpinner() { + return this.identityInput.locator('~ div svg.animate-spin'); + } + + // Identity validation icons + get identityValidIcon() { + return this.page.locator('svg path[d="M5 13l4 4L19 7"]').first(); + } + + get identityInvalidIcon() { + return this.page.locator('svg path[d="M6 18L18 6M6 6l12 12"]').first(); + } + + // Error messages + get lookupError() { + return this.page.locator('.text-red-600, .text-red-400').first(); + } + + get formError() { + return this.page.locator('.bg-red-50, .bg-red-900\\/20').first(); + } + + // External links + get faucetLink() { + return this.page.getByRole('link', { name: /faucet/i }); + } + + get bridgeLink() { + return this.page.getByRole('link', { name: /bridge/i }); + } + + // Password unlock modal + get passwordUnlockModal() { + return this.page.locator('[role="dialog"]'); + } + + async goto() { + await this.page.goto('/login'); + await this.waitForPageLoad(); + } + + // Enter identity ID and wait for lookup + async enterIdentity(identity: string) { + await this.identityInput.fill(identity); + // Wait for debounce and lookup + await this.page.waitForTimeout(600); + // Wait for loading to complete + await this.page.waitForFunction(() => { + const spinner = document.querySelector('svg.animate-spin'); + return !spinner || spinner.closest('#identityInput + div') === null; + }, { timeout: 30000 }); + } + + // Enter credential (password or private key) + async enterCredential(credential: string) { + await this.credentialInput.fill(credential); + // Wait for key validation if it's a key + await this.page.waitForTimeout(400); + } + + // Toggle remember me + async toggleRememberMe() { + await this.rememberMeToggle.click(); + } + + // Submit login form + async submitLogin() { + await this.signInButton.click(); + } + + // Full login flow + async login(identity: string, credential: string, options?: { rememberMe?: boolean }) { + await this.enterIdentity(identity); + await this.waitForIdentityValidation(); + await this.enterCredential(credential); + await this.waitForKeyValidation(); + + if (options?.rememberMe === false) { + // Toggle off if currently on + const isOn = await this.rememberMeToggle.getAttribute('aria-checked'); + if (isOn === 'true') { + await this.toggleRememberMe(); + } + } + + await this.submitLogin(); + } + + // Wait for identity lookup to complete + async waitForIdentityValidation(timeout: number = 30000) { + await this.page.waitForTimeout(500); + // Wait for either valid or invalid indicator + await Promise.race([ + this.page.waitForSelector('svg path[d="M5 13l4 4L19 7"]', { timeout }), + this.page.waitForSelector('.text-red-600', { timeout }), + ]).catch(() => {}); + } + + // Wait for key validation to complete + async waitForKeyValidation(timeout: number = 30000) { + await this.page.waitForTimeout(400); + // Wait for validation to complete + await Promise.race([ + this.page.waitForSelector('svg path[d="M5 13l4 4L19 7"]', { timeout }), + this.page.waitForSelector('svg path[d="M6 18L18 6M6 6l12 12"]', { timeout }), + ]).catch(() => {}); + } + + // Check if identity is resolved successfully + async isIdentityResolved(): Promise { + try { + await this.identityValidIcon.waitFor({ state: 'visible', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + // Check if login button is enabled + async isLoginEnabled(): Promise { + return !(await this.signInButton.isDisabled()); + } +} diff --git a/tests/pages/messages.page.ts b/tests/pages/messages.page.ts new file mode 100644 index 00000000..d582b95f --- /dev/null +++ b/tests/pages/messages.page.ts @@ -0,0 +1,140 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class MessagesPage extends BasePage { + // Conversations list + get conversationsList() { + return this.page.locator('[data-conversations], ul, div').filter({ has: this.page.locator('[data-conversation]') }).first(); + } + + get conversations() { + return this.page.locator('[data-conversation], li, div').filter({ has: this.page.locator('img, [data-avatar]') }); + } + + // Search conversations + get searchInput() { + return this.page.getByPlaceholder(/search.*conversations|search.*messages/i); + } + + // New conversation button + get newConversationButton() { + return this.page.getByRole('button', { name: /new|compose|start/i }); + } + + // Conversation thread elements + get messageThread() { + return this.page.locator('[data-messages], div').filter({ has: this.page.locator('[data-message]') }).first(); + } + + get messages() { + return this.page.locator('[data-message], div').filter({ hasText: /.+/ }); + } + + get messageInput() { + return this.page.getByPlaceholder(/message|type|write/i); + } + + get sendButton() { + return this.page.getByRole('button', { name: /send/i }); + } + + // Back button (mobile) + get backButton() { + return this.page.getByRole('button', { name: /back/i }); + } + + // Conversation header + get conversationHeader() { + return this.page.locator('header, div').filter({ has: this.page.locator('img, [data-avatar]') }).first(); + } + + get participantName() { + return this.conversationHeader.locator('span, h2').first(); + } + + // Empty states + get noConversations() { + return this.page.getByText(/no conversations|no messages yet/i); + } + + get noMessages() { + return this.page.getByText(/no messages yet|start a conversation/i); + } + + // New conversation modal + get newConversationModal() { + return this.page.locator('[role="dialog"]'); + } + + get participantInput() { + return this.newConversationModal.getByPlaceholder(/username|identity/i); + } + + get startConversationButton() { + return this.newConversationModal.getByRole('button', { name: /start|create|send/i }); + } + + async goto() { + await this.page.goto('/messages'); + await this.waitForPageLoad(); + } + + // Wait for conversations to load + async waitForConversationsLoad(timeout: number = 30000) { + await this.page.waitForLoadState('networkidle', { timeout }); + } + + // Search conversations + async searchConversations(query: string) { + await this.searchInput.fill(query); + await this.page.waitForTimeout(500); + } + + // Open conversation by index + async openConversation(index: number = 0) { + await this.conversations.nth(index).click(); + await this.page.waitForTimeout(500); + } + + // Start new conversation + async startNewConversation(participant: string, message: string) { + await this.newConversationButton.click(); + await this.newConversationModal.waitFor({ state: 'visible' }); + await this.participantInput.fill(participant); + await this.page.waitForTimeout(500); + await this.startConversationButton.click(); + await this.page.waitForTimeout(500); + // Send first message + await this.sendMessage(message); + } + + // Send message + async sendMessage(message: string) { + await this.messageInput.fill(message); + await this.sendButton.click(); + await this.page.waitForTimeout(1000); + } + + // Get conversation count + async getConversationCount(): Promise { + await this.page.waitForTimeout(500); + return await this.conversations.count(); + } + + // Get message count in current conversation + async getMessageCount(): Promise { + await this.page.waitForTimeout(500); + return await this.messages.count(); + } + + // Go back to conversations list (mobile) + async goBack() { + await this.backButton.click(); + await this.page.waitForTimeout(300); + } + + // Get participant name of current conversation + async getParticipantName(): Promise { + return await this.participantName.textContent() || ''; + } +} diff --git a/tests/pages/profile-create.page.ts b/tests/pages/profile-create.page.ts new file mode 100644 index 00000000..c9d93620 --- /dev/null +++ b/tests/pages/profile-create.page.ts @@ -0,0 +1,163 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class ProfileCreatePage extends BasePage { + // Form fields + get displayNameInput() { + return this.page.getByLabel(/display name/i); + } + + get bioInput() { + return this.page.getByLabel(/bio/i); + } + + get pronounsInput() { + return this.page.getByLabel(/pronouns/i); + } + + get locationInput() { + return this.page.getByLabel(/location/i); + } + + get websiteInput() { + return this.page.getByLabel(/website/i); + } + + // Avatar section + get avatarPreview() { + return this.page.locator('img[alt*="avatar"], [data-avatar]').first(); + } + + get editAvatarButton() { + return this.page.getByRole('button', { name: /edit avatar|change avatar/i }); + } + + get randomizeAvatarButton() { + return this.page.getByRole('button', { name: /randomize|random/i }); + } + + // Payment addresses + get addPaymentButton() { + return this.page.getByRole('button', { name: /add.*payment|add.*address/i }); + } + + get paymentInputs() { + return this.page.locator('input[placeholder*="address"], input[name*="payment"]'); + } + + // Social links + get addSocialButton() { + return this.page.getByRole('button', { name: /add.*social|add.*link/i }); + } + + get socialInputs() { + return this.page.locator('input[placeholder*="handle"], input[name*="social"]'); + } + + // NSFW toggle + get nsfwToggle() { + return this.page.getByRole('switch').filter({ hasText: /nsfw|adult/i }); + } + + // Private key input (if needed) + get privateKeyInput() { + return this.page.getByLabel(/private key/i); + } + + // Submit button + get submitButton() { + return this.page.getByRole('button', { name: /create|save|submit/i }); + } + + // Cancel button + get cancelButton() { + return this.page.getByRole('button', { name: /cancel/i }); + } + + // Character counters + get bioCounter() { + return this.page.locator('span').filter({ hasText: /\/200/ }); + } + + // Error messages + get errorMessage() { + return this.page.locator('.text-red-600, .text-red-400, .bg-red-50').first(); + } + + // Success message + get successMessage() { + return this.page.locator('.text-green-600, .text-green-400, .bg-green-50').first(); + } + + async goto() { + await this.page.goto('/profile/create'); + await this.waitForPageLoad(); + } + + // Fill display name + async fillDisplayName(name: string) { + await this.displayNameInput.fill(name); + } + + // Fill bio + async fillBio(bio: string) { + await this.bioInput.fill(bio); + } + + // Fill pronouns + async fillPronouns(pronouns: string) { + await this.pronounsInput.fill(pronouns); + } + + // Fill location + async fillLocation(location: string) { + await this.locationInput.fill(location); + } + + // Fill website + async fillWebsite(website: string) { + await this.websiteInput.fill(website); + } + + // Fill private key + async fillPrivateKey(key: string) { + if (await this.privateKeyInput.isVisible()) { + await this.privateKeyInput.fill(key); + } + } + + // Submit form + async submit() { + await this.submitButton.click(); + } + + // Create profile (full flow) + async createProfile(data: { + displayName: string; + bio?: string; + pronouns?: string; + location?: string; + website?: string; + privateKey?: string; + }) { + await this.fillDisplayName(data.displayName); + if (data.bio) await this.fillBio(data.bio); + if (data.pronouns) await this.fillPronouns(data.pronouns); + if (data.location) await this.fillLocation(data.location); + if (data.website) await this.fillWebsite(data.website); + if (data.privateKey) await this.fillPrivateKey(data.privateKey); + await this.submit(); + } + + // Check if form is valid (submit button enabled) + async isFormValid(): Promise { + return !(await this.submitButton.isDisabled()); + } + + // Get bio character count + async getBioCharCount(): Promise { + const text = await this.bioCounter.textContent(); + const match = text?.match(/(\d+)\/200/); + return match ? parseInt(match[1]) : 0; + } +} diff --git a/tests/pages/profile.page.ts b/tests/pages/profile.page.ts new file mode 100644 index 00000000..603e3c50 --- /dev/null +++ b/tests/pages/profile.page.ts @@ -0,0 +1,171 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class ProfilePage extends BasePage { + // Profile header + get displayName() { + return this.page.locator('h1, h2').first(); + } + + get username() { + return this.page.locator('span, p').filter({ hasText: /@|\.dash/ }).first(); + } + + get bio() { + return this.page.locator('[data-bio], p').filter({ hasNotText: /@|\.dash|followers|following/ }).first(); + } + + get avatar() { + return this.page.locator('img[alt*="avatar"], [data-avatar]').first(); + } + + // Stats + get postCount() { + return this.page.locator('span, div').filter({ hasText: /posts?$/i }).first(); + } + + get followersCount() { + return this.page.getByRole('link', { name: /followers/i }).first(); + } + + get followingCount() { + return this.page.getByRole('link', { name: /following/i }).first(); + } + + // Action buttons + get editProfileButton() { + return this.page.getByRole('button', { name: /edit profile/i }); + } + + get followButton() { + return this.page.getByRole('button', { name: /^follow$/i }); + } + + get unfollowButton() { + return this.page.getByRole('button', { name: /following|unfollow/i }); + } + + get settingsButton() { + return this.page.getByRole('link', { name: /settings/i }); + } + + get shareButton() { + return this.page.getByRole('button', { name: /share/i }); + } + + get backButton() { + return this.page.getByRole('button', { name: /back/i }); + } + + // Profile info + get location() { + return this.page.locator('span, p').filter({ has: this.page.locator('[class*="MapPin"]') }); + } + + get website() { + return this.page.locator('a[href^="http"]').first(); + } + + // User posts section + get postsSection() { + return this.page.locator('section, div').filter({ has: this.page.locator('article') }); + } + + get posts() { + return this.page.locator('article'); + } + + // Empty state + get noPosts() { + return this.page.getByText(/no posts yet/i); + } + + // Blocked user notice + get blockedNotice() { + return this.page.getByText(/blocked/i); + } + + // Migration banner + get migrationBanner() { + return this.page.getByText(/migrate/i); + } + + async goto(userId?: string) { + if (userId) { + await this.page.goto(`/user?id=${userId}`); + } else { + await this.page.goto('/profile'); + } + await this.waitForPageLoad(); + } + + // Wait for profile to load + async waitForProfileLoad(timeout: number = 30000) { + await this.page.waitForLoadState('networkidle', { timeout }); + } + + // Follow user + async follow() { + await this.followButton.click(); + await this.page.waitForTimeout(2000); + } + + // Unfollow user + async unfollow() { + await this.unfollowButton.click(); + await this.page.waitForTimeout(2000); + } + + // Check if following + async isFollowing(): Promise { + return await this.unfollowButton.isVisible().catch(() => false); + } + + // Edit profile (opens create/edit form) + async editProfile() { + await this.editProfileButton.click(); + await this.page.waitForURL(/\/profile\/create/); + } + + // Navigate to followers + async viewFollowers() { + await this.followersCount.click(); + await this.page.waitForURL(/\/followers/); + } + + // Navigate to following + async viewFollowing() { + await this.followingCount.click(); + await this.page.waitForURL(/\/following/); + } + + // Share profile + async shareProfile() { + await this.shareButton.click(); + await this.page.waitForTimeout(500); + } + + // Get display name text + async getDisplayName(): Promise { + return await this.displayName.textContent() || ''; + } + + // Get bio text + async getBio(): Promise { + return await this.bio.textContent() || ''; + } + + // Get followers count + async getFollowersCount(): Promise { + const text = await this.followersCount.textContent(); + const match = text?.match(/(\d+)/); + return match ? parseInt(match[1]) : 0; + } + + // Get following count + async getFollowingCount(): Promise { + const text = await this.followingCount.textContent(); + const match = text?.match(/(\d+)/); + return match ? parseInt(match[1]) : 0; + } +} diff --git a/tests/pages/settings.page.ts b/tests/pages/settings.page.ts new file mode 100644 index 00000000..75be107f --- /dev/null +++ b/tests/pages/settings.page.ts @@ -0,0 +1,191 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './base.page'; + +export class SettingsPage extends BasePage { + // Navigation tabs/sections + get accountSection() { + return this.page.getByRole('button', { name: /account/i }); + } + + get notificationsSection() { + return this.page.getByRole('button', { name: /notifications/i }); + } + + get privacySection() { + return this.page.getByRole('button', { name: /privacy|security/i }); + } + + get appearanceSection() { + return this.page.getByRole('button', { name: /appearance|theme/i }); + } + + get aboutSection() { + return this.page.getByRole('button', { name: /about/i }); + } + + // Account section elements + get identityId() { + return this.page.locator('code, span').filter({ hasText: /^[1-9A-HJ-NP-Za-km-z]{42,46}$/ }).first(); + } + + get dashBalance() { + return this.page.locator('span, p').filter({ hasText: /dash|credits/i }).first(); + } + + get logoutButton() { + return this.page.getByRole('button', { name: /logout|sign out/i }); + } + + get deleteAccountButton() { + return this.page.getByRole('button', { name: /delete account/i }); + } + + // Notification toggles + get likesNotificationToggle() { + return this.page.getByRole('switch', { name: /likes/i }); + } + + get repostsNotificationToggle() { + return this.page.getByRole('switch', { name: /reposts/i }); + } + + get repliesNotificationToggle() { + return this.page.getByRole('switch', { name: /replies/i }); + } + + get followsNotificationToggle() { + return this.page.getByRole('switch', { name: /follows/i }); + } + + get mentionsNotificationToggle() { + return this.page.getByRole('switch', { name: /mentions/i }); + } + + get messagesNotificationToggle() { + return this.page.getByRole('switch', { name: /messages/i }); + } + + // Privacy section elements + get publicProfileToggle() { + return this.page.getByRole('switch', { name: /public profile/i }); + } + + get activityStatusToggle() { + return this.page.getByRole('switch', { name: /activity status/i }); + } + + get dmPrivacySelect() { + return this.page.locator('select, [role="listbox"]').filter({ has: this.page.locator('text=/everyone|followers|no one/i') }); + } + + get viewBlockedButton() { + return this.page.getByRole('button', { name: /view blocked|blocked users/i }); + } + + get keyBackupButton() { + return this.page.getByRole('button', { name: /backup|key backup/i }); + } + + // Appearance section elements + get lightThemeOption() { + return this.page.getByRole('radio', { name: /light/i }); + } + + get darkThemeOption() { + return this.page.getByRole('radio', { name: /dark/i }); + } + + get systemThemeOption() { + return this.page.getByRole('radio', { name: /system/i }); + } + + // About section elements + get versionInfo() { + return this.page.locator('span, p').filter({ hasText: /version|v\d/i }); + } + + get contractLink() { + return this.page.getByRole('link', { name: /contract/i }); + } + + get githubLink() { + return this.page.getByRole('link', { name: /github/i }); + } + + async goto() { + await this.page.goto('/settings'); + await this.waitForPageLoad(); + } + + // Navigate to account section + async goToAccount() { + await this.accountSection.click(); + await this.page.waitForTimeout(300); + } + + // Navigate to notifications section + async goToNotifications() { + await this.notificationsSection.click(); + await this.page.waitForTimeout(300); + } + + // Navigate to privacy section + async goToPrivacy() { + await this.privacySection.click(); + await this.page.waitForTimeout(300); + } + + // Navigate to appearance section + async goToAppearance() { + await this.appearanceSection.click(); + await this.page.waitForTimeout(300); + } + + // Navigate to about section + async goToAbout() { + await this.aboutSection.click(); + await this.page.waitForTimeout(300); + } + + // Logout + async logout() { + await this.logoutButton.click(); + await this.page.waitForURL(/\/login|\//); + } + + // Set theme + async setTheme(theme: 'light' | 'dark' | 'system') { + switch (theme) { + case 'light': + await this.lightThemeOption.click(); + break; + case 'dark': + await this.darkThemeOption.click(); + break; + case 'system': + await this.systemThemeOption.click(); + break; + } + await this.page.waitForTimeout(300); + } + + // Toggle notification setting + async toggleNotification(type: 'likes' | 'reposts' | 'replies' | 'follows' | 'mentions' | 'messages') { + const toggleMap = { + likes: this.likesNotificationToggle, + reposts: this.repostsNotificationToggle, + replies: this.repliesNotificationToggle, + follows: this.followsNotificationToggle, + mentions: this.mentionsNotificationToggle, + messages: this.messagesNotificationToggle, + }; + await toggleMap[type].click(); + await this.page.waitForTimeout(300); + } + + // View blocked users + async viewBlockedUsers() { + await this.viewBlockedButton.click(); + await this.page.waitForTimeout(500); + } +}