From 6112f3574412dd419ebd0e338fad4883946b3c28 Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Fri, 8 May 2026 13:54:39 +0200 Subject: [PATCH 01/39] fix: update test imports to use local fixtures for consistency --- playwright.config.ts | 2 +- src/components/ui/form/MultiSelect.tsx | 2 +- tests/accessibility.spec.ts | 2 +- tests/admin-dashboard.spec.ts | 2 +- tests/admin-permissions.spec.ts | 2 +- tests/admin-reports.spec.ts | 2 +- tests/admin-users.spec.ts | 2 +- tests/android-downloads.spec.ts | 2 +- tests/auth.setup.ts | 3 ++ tests/auth.spec.ts | 2 +- tests/badge-system.spec.ts | 2 +- tests/browsing.spec.ts | 2 +- tests/commenting.spec.ts | 2 +- tests/custom-fields.spec.ts | 2 +- tests/error-handling.spec.ts | 2 +- tests/filtering.spec.ts | 31 +++++++------ tests/fixtures.ts | 15 +++++++ tests/forms.spec.ts | 2 +- tests/full-listing-flow.spec.ts | 2 +- tests/game-management.spec.ts | 2 +- tests/helpers/cookie-consent.ts | 48 +++++++++++++++++++++ tests/helpers/data-factory.ts | 19 ++------ tests/helpers/test-config.ts | 25 ----------- tests/igdb-search.spec.ts | 2 +- tests/listing-approval.spec.ts | 2 +- tests/listings-success-rate-sorting.spec.ts | 2 +- tests/navigation.spec.ts | 2 +- tests/notification-system.spec.ts | 4 +- tests/pages/BasePage.ts | 17 -------- tests/pagination.spec.ts | 4 +- tests/pc-listings.spec.ts | 2 +- tests/pc-voting.spec.ts | 2 +- tests/performance.spec.ts | 2 +- tests/router-coverage.spec.ts | 2 +- tests/search.spec.ts | 2 +- tests/trust-integration.spec.ts | 4 +- tests/trust-system.spec.ts | 2 +- tests/user-flows.spec.ts | 2 +- tests/user-moderation.spec.ts | 2 +- tests/voting.spec.ts | 7 +-- 40 files changed, 122 insertions(+), 113 deletions(-) create mode 100644 tests/fixtures.ts create mode 100644 tests/helpers/cookie-consent.ts diff --git a/playwright.config.ts b/playwright.config.ts index 020df9ead..f82b7d4bd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import path from 'path' import { defineConfig, devices } from '@playwright/test' import dotenv from 'dotenv' -/** Read environment variables from file. */ +dotenv.config({ path: path.resolve(__dirname, '.env.local') }) dotenv.config({ path: path.resolve(__dirname, '.env.test.local') }) const isCI = !!process.env.CI diff --git a/src/components/ui/form/MultiSelect.tsx b/src/components/ui/form/MultiSelect.tsx index 9074263d7..38b5ff82c 100644 --- a/src/components/ui/form/MultiSelect.tsx +++ b/src/components/ui/form/MultiSelect.tsx @@ -147,7 +147,7 @@ export function MultiSelect(props: Props) { {props.label} -
+
- {/* Progress bar showing success rate / verified */} -
+
{ await listingsPage.clickFirstListing() await page.waitForLoadState('domcontentloaded') - await expect(page.getByText(/\d+%/)).toBeVisible() - await expect(page.getByText(/verified by \d+ users/i)).toBeVisible() + const progressBar = page.getByRole('progressbar', { name: /success rate/i }) + await expect(progressBar).toBeVisible() + + const valueNow = await progressBar.getAttribute('aria-valuenow') + expect(valueNow).toMatch(/^\d+$/) + const numericValue = Number(valueNow) + expect(numericValue).toBeGreaterThanOrEqual(0) + expect(numericValue).toBeLessThanOrEqual(100) }) }) From b64c1fddec91db9bd8ebac3008af8c649df539bf Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Fri, 8 May 2026 20:52:29 +0200 Subject: [PATCH 08/39] fix: enhance accessibility of success rate bar with ARIA attributes --- src/components/ui/SuccessRateBar.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/ui/SuccessRateBar.tsx b/src/components/ui/SuccessRateBar.tsx index 42c2e25f9..d3d49ec02 100644 --- a/src/components/ui/SuccessRateBar.tsx +++ b/src/components/ui/SuccessRateBar.tsx @@ -20,6 +20,16 @@ export function SuccessRateBar(props: Props) { return (
0 + ? `${roundedRate}% based on ${voteCount} ${voteCount === 1 ? 'vote' : 'votes'}` + : `${roundedRate}%` + } className={cn( 'w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden', compact ? 'h-1.5' : 'h-2', From e8f10188c0fa5b2653259fddb07c87f81edbd3dd Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Fri, 8 May 2026 23:06:11 +0200 Subject: [PATCH 09/39] fix: refactor PC listing creation and approval process in tests --- tests/data-setup.spec.ts | 23 ++---------- tests/helpers/data-factory.ts | 19 +++++++++- tests/pc-listings.spec.ts | 39 ++++++++++++------- tests/pc-voting.spec.ts | 71 +++++++++++++++++++---------------- 4 files changed, 84 insertions(+), 68 deletions(-) diff --git a/tests/data-setup.spec.ts b/tests/data-setup.spec.ts index f28dc5af9..92451ed9a 100644 --- a/tests/data-setup.spec.ts +++ b/tests/data-setup.spec.ts @@ -1,16 +1,5 @@ import { test } from '@playwright/test' -import { - createHandheldListing, - createPcListing, - createReport, - withContext, -} from './helpers/data-factory' - -// Runs after auth setup, before all specs. Creates baseline data other tests -// depend on. The test user has enough trust score that their listings are -// auto-approved on creation โ€” no explicit approve step needed. Pending -// listings for approval tests come from the seeder. If any step fails, the -// Playwright project-dependency system cascades skips to downstream specs. +import { createHandheldListing, createReport, withContext } from './helpers/data-factory' test.describe.serial('Test Data Setup', () => { test.setTimeout(120000) @@ -21,15 +10,9 @@ test.describe.serial('Test Data Setup', () => { }) }) - test('ensure approved PC listing exists', async ({ browser }) => { - await withContext(browser, 'tests/.auth/user.json', async (page) => { - await createPcListing(page) - }) - }) - test('ensure report exists for admin-reports tests', async ({ browser }) => { - // Use a different user than the listing author โ€” the Report button is - // hidden when viewing your own listings. + // Reporter must differ from the listing author โ€” the Report button is + // hidden on listings you authored. await withContext(browser, 'tests/.auth/author.json', async (page) => { await createReport(page) }) diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index dc23a5e98..b2ac1eb9c 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -160,7 +160,7 @@ export async function createHandheldListing(page: Page): Promise { await expect(page).toHaveURL(/\/listings(?!\/new)/) } -export async function createPcListing(page: Page): Promise { +export async function createPcListing(page: Page): Promise { await page.goto('/pc-listings/new') await page.waitForLoadState('domcontentloaded') @@ -191,7 +191,22 @@ export async function createPcListing(page: Page): Promise { await page.getByRole('button', { name: /create compatibility report/i }).click() - await expect(page).toHaveURL(/\/pc-listings/) + await page.waitForURL(/\/pc-listings\/(?!new)[^/?#]+/) + return page.url() +} + +export async function createApprovedPcListing(browser: Browser): Promise { + let detailUrl = '' + + await withContext(browser, 'tests/.auth/user.json', async (page) => { + detailUrl = await createPcListing(page) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await approveFirstPendingListing(page, '/admin/pc-listing-approvals') + }) + + return detailUrl } export async function approveFirstPendingListing( diff --git a/tests/pc-listings.spec.ts b/tests/pc-listings.spec.ts index 8b61296e5..df1bb8c47 100644 --- a/tests/pc-listings.spec.ts +++ b/tests/pc-listings.spec.ts @@ -1,5 +1,6 @@ import path from 'path' import { test, expect } from './fixtures' +import { createApprovedPcListing } from './helpers/data-factory' test.describe('PC Listings', () => { test.describe('PC Listings Page', () => { @@ -20,10 +21,16 @@ test.describe('PC Listings', () => { await expect(page.locator('th').filter({ hasText: /emulator/i })).toBeVisible() }) - test('should display table rows', async ({ page }) => { - await page.goto('/pc-listings') + test.describe('with at least one approved listing', () => { + test.beforeAll(async ({ browser }) => { + await createApprovedPcListing(browser) + }) + + test('should display table rows', async ({ page }) => { + await page.goto('/pc-listings') - await expect(page.locator('tbody tr').first()).toBeVisible() + await expect(page.locator('tbody tr').first()).toBeVisible() + }) }) test('should sort by CPU column', async ({ page }) => { @@ -64,16 +71,22 @@ test.describe('PC Listings', () => { }) test.describe('PC Listings Navigation', () => { - test('should navigate to PC listing detail on row click', async ({ page }) => { - await page.goto('/pc-listings') - await page.waitForLoadState('domcontentloaded') - - const rows = page.locator('tbody tr') - await expect(rows.first()).toBeVisible() - - const link = rows.first().locator('a[href*="/pc-listings/"]').first() - await link.click() - await expect(page).toHaveURL(/\/pc-listings\/[a-z0-9]/) + test.describe('with at least one approved listing', () => { + test.beforeAll(async ({ browser }) => { + await createApprovedPcListing(browser) + }) + + test('should navigate to PC listing detail on row click', async ({ page }) => { + await page.goto('/pc-listings') + await page.waitForLoadState('domcontentloaded') + + const rows = page.locator('tbody tr') + await expect(rows.first()).toBeVisible() + + const link = rows.first().locator('a[href*="/pc-listings/"]').first() + await link.click() + await expect(page).toHaveURL(/\/pc-listings\/[a-z0-9]/) + }) }) test('should show Add PC Report button', async ({ page }) => { diff --git a/tests/pc-voting.spec.ts b/tests/pc-voting.spec.ts index a4b5a997c..487a6a46d 100644 --- a/tests/pc-voting.spec.ts +++ b/tests/pc-voting.spec.ts @@ -1,22 +1,16 @@ -import { type Page, test, expect } from './fixtures' +import { test, expect } from './fixtures' +import { createApprovedPcListing } from './helpers/data-factory' -// Mirrors voting.spec.ts for handheld listings โ€” PcVoteButtons wraps the -// shared VoteButtons component, so both should behave identically. - -async function navigateToFirstPcListing(page: Page) { - await page.goto('/pc-listings') - await page.waitForLoadState('domcontentloaded') - - const rows = page.locator('tbody tr') - await expect(rows.first()).toBeVisible() +test.describe('PC Listing Voting Functionality Tests', () => { + let pcListingUrl = '' - await rows.first().locator('a').first().click() - await page.waitForLoadState('domcontentloaded') -} + test.beforeAll(async ({ browser }) => { + pcListingUrl = await createApprovedPcListing(browser) + }) -test.describe('PC Listing Voting Functionality Tests', () => { test('should display vote section on PC listing detail page', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const verificationHeading = page.getByRole('heading', { name: /community verification/i, @@ -25,7 +19,8 @@ test.describe('PC Listing Voting Functionality Tests', () => { }) test('should display confirm and inaccurate vote buttons', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const confirmButton = page.getByRole('button', { name: /confirm/i }) const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) @@ -35,7 +30,8 @@ test.describe('PC Listing Voting Functionality Tests', () => { }) test('should display success rate percentage and voter count', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const successRate = page.getByText(/\d+%/) await expect(successRate.first()).toBeVisible() @@ -45,14 +41,16 @@ test.describe('PC Listing Voting Functionality Tests', () => { }) test('should show help button for voting explanation', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const helpButton = page.getByTitle('How does verification work?') await expect(helpButton).toBeVisible() }) test('should show sign-in prompt for unauthenticated users', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const signInToVerify = page .getByText(/sign in/i) @@ -62,7 +60,8 @@ test.describe('PC Listing Voting Functionality Tests', () => { }) test('should have vote buttons disabled for unauthenticated users', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const confirmButton = page.getByRole('button', { name: /confirm/i }) await expect(confirmButton).toBeVisible() @@ -73,57 +72,63 @@ test.describe('PC Listing Voting Functionality Tests', () => { }) test('should display progress bar for success rate', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') - const verificationHeading = page.getByRole('heading', { - name: /community verification/i, - }) - await expect(verificationHeading).toBeVisible() + const progressBar = page.getByRole('progressbar', { name: /success rate/i }) + await expect(progressBar).toBeVisible() - await expect(page.getByText(/\d+%/)).toBeVisible() - await expect(page.getByText(/verified by \d+ users/i)).toBeVisible() + const valueNow = await progressBar.getAttribute('aria-valuenow') + expect(valueNow).toMatch(/^\d+$/) + const numericValue = Number(valueNow) + expect(numericValue).toBeGreaterThanOrEqual(0) + expect(numericValue).toBeLessThanOrEqual(100) }) }) test.describe('PC Listing Vote โ€” Toggle and Change Flows (Authenticated)', () => { test.use({ storageState: 'tests/.auth/user.json' }) + let pcListingUrl = '' + + test.beforeAll(async ({ browser }) => { + pcListingUrl = await createApprovedPcListing(browser) + }) + test('toggling the same vote twice returns the button to its unpressed state', async ({ page, }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const confirmButton = page.getByRole('button', { name: /confirm/i }) await expect(confirmButton).toBeVisible() const initial = await confirmButton.getAttribute('aria-pressed') - // Click once โ†’ flip await confirmButton.click() await expect(confirmButton).toHaveAttribute( 'aria-pressed', initial === 'true' ? 'false' : 'true', ) - // Click again โ†’ flip back await confirmButton.click() await expect(confirmButton).toHaveAttribute('aria-pressed', initial ?? 'false') }) test('changing from upvote to downvote flips aria-pressed on both buttons', async ({ page }) => { - await navigateToFirstPcListing(page) + await page.goto(pcListingUrl) + await page.waitForLoadState('domcontentloaded') const confirmButton = page.getByRole('button', { name: /confirm/i }) const inaccurateButton = page.getByRole('button', { name: /inaccurate/i }) - // Ensure upvote is active const confirmPressed = await confirmButton.getAttribute('aria-pressed') if (confirmPressed !== 'true') { await confirmButton.click() await expect(confirmButton).toHaveAttribute('aria-pressed', 'true') } - // Change to downvote await inaccurateButton.click() await expect(inaccurateButton).toHaveAttribute('aria-pressed', 'true') await expect(confirmButton).toHaveAttribute('aria-pressed', 'false') From d7627e9466d54a6553c454c3cd5850e75b0b13ea Mon Sep 17 00:00:00 2001 From: ObfuscatedVoid Date: Sat, 9 May 2026 12:07:21 +0200 Subject: [PATCH 10/39] fix: refactor PC listing approval process and improve test setup --- tests/data-setup.spec.ts | 13 +++++++- tests/filtering.spec.ts | 2 +- tests/fixtures.ts | 6 ++-- tests/helpers/data-factory.ts | 22 ++++++++++++- tests/notification-system.spec.ts | 2 +- tests/pages/AuthPage.ts | 3 +- tests/pagination.spec.ts | 2 +- tests/pc-listings.spec.ts | 39 ++++++++-------------- tests/pc-voting.spec.ts | 54 +++++++++++++------------------ tests/trust-integration.spec.ts | 12 +++---- tests/voting.spec.ts | 4 ++- 11 files changed, 83 insertions(+), 76 deletions(-) diff --git a/tests/data-setup.spec.ts b/tests/data-setup.spec.ts index 92451ed9a..1169f5b3c 100644 --- a/tests/data-setup.spec.ts +++ b/tests/data-setup.spec.ts @@ -1,5 +1,10 @@ import { test } from '@playwright/test' -import { createHandheldListing, createReport, withContext } from './helpers/data-factory' +import { + ensureApprovedPcListing, + createHandheldListing, + createReport, + withContext, +} from './helpers/data-factory' test.describe.serial('Test Data Setup', () => { test.setTimeout(120000) @@ -10,6 +15,12 @@ test.describe.serial('Test Data Setup', () => { }) }) + test('ensure approved PC listing exists', async ({ browser }) => { + // Seed user has trust score 0 and creates pending PC listings, so the + // listing is created as a regular user and then approved as super_admin. + await ensureApprovedPcListing(browser) + }) + test('ensure report exists for admin-reports tests', async ({ browser }) => { // Reporter must differ from the listing author โ€” the Report button is // hidden on listings you authored. diff --git a/tests/filtering.spec.ts b/tests/filtering.spec.ts index 5208ea435..1ab736b53 100644 --- a/tests/filtering.spec.ts +++ b/tests/filtering.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from './fixtures' import { ListingsPage } from './pages/ListingsPage' -import type { Locator, Page } from './fixtures' +import type { Locator, Page } from '@playwright/test' async function selectFirstFilterOption(page: Page, filterButton: Locator) { await filterButton.click() diff --git a/tests/fixtures.ts b/tests/fixtures.ts index acaf67b53..4e43fd08c 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -2,9 +2,8 @@ import { test as base } from '@playwright/test' import { registerCookieConsent } from './helpers/cookie-consent' export const test = base.extend({ - // The fixture-API callback is `use` by convention. Renamed to `run` - // so Next.js's `react-hooks/rules-of-hooks` ESLint rule does not - // misclassify `use(page)` as a React Hook call. + // Renamed `use` โ†’ `run` so Next.js's react-hooks/rules-of-hooks lint rule + // does not misclassify `use(page)` as a React Hook call. page: async ({ page }, run) => { await registerCookieConsent(page.context()) await run(page) @@ -12,4 +11,3 @@ export const test = base.extend({ }) export { expect } from '@playwright/test' -export type { Page, Locator } from '@playwright/test' diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index b2ac1eb9c..8577506db 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -195,7 +195,10 @@ export async function createPcListing(page: Page): Promise { return page.url() } -export async function createApprovedPcListing(browser: Browser): Promise { +export async function ensureApprovedPcListing(browser: Browser): Promise { + const existing = await findFirstApprovedPcListing(browser) + if (existing) return existing + let detailUrl = '' await withContext(browser, 'tests/.auth/user.json', async (page) => { @@ -209,6 +212,23 @@ export async function createApprovedPcListing(browser: Browser): Promise return detailUrl } +async function findFirstApprovedPcListing(browser: Browser): Promise { + let detailUrl: string | null = null + + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/pc-listings') + await page.waitForLoadState('domcontentloaded') + + const firstLink = page.locator('tbody tr a[href*="/pc-listings/"]').first() + if (!(await firstLink.isVisible({ timeout: 2000 }).catch(() => false))) return + + const href = await firstLink.getAttribute('href') + if (href) detailUrl = new URL(href, page.url()).href + }) + + return detailUrl +} + export async function approveFirstPendingListing( page: Page, approvalPath: '/admin/approvals' | '/admin/pc-listing-approvals', diff --git a/tests/notification-system.spec.ts b/tests/notification-system.spec.ts index eb4248751..d7eb25e72 100644 --- a/tests/notification-system.spec.ts +++ b/tests/notification-system.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from './fixtures' -import type { Page } from './fixtures' +import type { Page } from '@playwright/test' function bellButton(page: Page) { return page diff --git a/tests/pages/AuthPage.ts b/tests/pages/AuthPage.ts index 29fb79ea8..06757877d 100644 --- a/tests/pages/AuthPage.ts +++ b/tests/pages/AuthPage.ts @@ -1,6 +1,5 @@ -import { expect } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { BasePage } from './BasePage' -import type { Page } from '@playwright/test' export class AuthPage extends BasePage { constructor(page: Page) { diff --git a/tests/pagination.spec.ts b/tests/pagination.spec.ts index cefde45da..ed6339e5e 100644 --- a/tests/pagination.spec.ts +++ b/tests/pagination.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from './fixtures' import { GamesPage } from './pages/GamesPage' import { ListingsPage } from './pages/ListingsPage' -import type { Page } from './fixtures' +import type { Page } from '@playwright/test' // The Pagination component renders its navigation buttons twice in the same //
, ) - // Open dropdown fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByPlaceholderText('Search test select...')).toBeInTheDocument() }) - // Click outside fireEvent.mouseDown(screen.getByTestId('outside')) await waitFor(() => { @@ -304,7 +297,6 @@ describe('MultiSelect', () => { const onChange = vi.fn() render() - // Open dropdown fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -312,13 +304,11 @@ describe('MultiSelect', () => { fireEvent.change(searchInput, { target: { value: 'test query' } }) }) - // Clear search button should appear const clearSearchButton = screen.getByRole('button', { name: 'Clear search', }) fireEvent.click(clearSearchButton) - // Search input should be cleared const searchInput = screen.getByPlaceholderText('Search test select...') expect(searchInput).toHaveValue('') }) @@ -382,17 +372,15 @@ describe('MultiSelect', () => { />, ) - // Open dropdown by clicking the main select button specifically const selectButton = screen.getByLabelText('Test Select multi-select') fireEvent.click(selectButton) await waitFor(() => { const labels = screen.getAllByRole('checkbox') - // Selected options should appear first - expect(labels[0]).toBeChecked() // Option 3 - expect(labels[1]).toBeChecked() // Option 4 - expect(labels[2]).not.toBeChecked() // Option 1 - expect(labels[3]).not.toBeChecked() // Option 2 + expect(labels[0]).toBeChecked() + expect(labels[1]).toBeChecked() + expect(labels[2]).not.toBeChecked() + expect(labels[3]).not.toBeChecked() }) }) }) diff --git a/tests/admin-reports.spec.ts b/tests/admin-reports.spec.ts index d052076e9..0d90a30f1 100644 --- a/tests/admin-reports.spec.ts +++ b/tests/admin-reports.spec.ts @@ -83,8 +83,6 @@ test.describe('Admin Reports Management Tests - Requires Admin Role', () => { test('should display report statistics', async ({ page }) => { const statsLabels = [/total reports/i, /pending/i, /under review/i, /resolved/i, /dismissed/i] - // These labels may appear multiple times on the page (stats, table status - // column, action buttons). Use .first() to match the stats label. for (const label of statsLabels) { await expect(page.getByText(label).first()).toBeVisible() } @@ -99,8 +97,6 @@ test.describe('Admin Reports Management Tests - Requires Admin Role', () => { const dialog = page.locator('[role="dialog"]') await expect(dialog).toBeVisible() - // "View Listing" is a button that opens the listing in a new tab via - // window.open โ€” not an anchor tag. const viewListingButton = dialog.getByRole('button', { name: /view listing/i }) await expect(viewListingButton).toBeVisible() diff --git a/tests/admin-users.spec.ts b/tests/admin-users.spec.ts index 73cc192fd..865b2f544 100644 --- a/tests/admin-users.spec.ts +++ b/tests/admin-users.spec.ts @@ -98,7 +98,6 @@ test.describe('Admin User Management Tests - Requires Admin Role', () => { const modal = page.locator('[role="dialog"]') await expect(modal).toBeVisible() - // Wait for modal content to finish loading await expect(modal.getByText(/loading/i)).toBeHidden() const tabButtons = modal.locator('button').filter({ hasText: /listings|votes|trust/i }) diff --git a/tests/browsing.spec.ts b/tests/browsing.spec.ts index 9021b1468..363f6683b 100644 --- a/tests/browsing.spec.ts +++ b/tests/browsing.spec.ts @@ -136,9 +136,9 @@ test.describe('Content Display Tests', () => { }) test('should show empty state when search returns no games', async ({ page }) => { - // A no-match search term exercises the same empty-state branch as a - // truly empty database, without needing to mock the tRPC query. - await page.goto('/games?search=zzqqxxnotarealgame42') + const noMatchSearchTerm = 'zzqqxxnotarealgame42' + + await page.goto(`/games?search=${noMatchSearchTerm}`) await expect(page.getByText(/no games found matching your criteria/i)).toBeVisible() }) diff --git a/tests/commenting.spec.ts b/tests/commenting.spec.ts index c833f4caf..9f821cc2b 100644 --- a/tests/commenting.spec.ts +++ b/tests/commenting.spec.ts @@ -80,9 +80,9 @@ test.describe('Commenting System Tests', () => { const commentCards = page.locator('[id^="comment-"]') const emptyState = page.getByText(/no comments yet/i) + const commentsContent = commentCards.first().or(emptyState) - // Either comments or empty state must be visible - await expect(commentCards.first().or(emptyState)).toBeVisible() + await expect(commentsContent).toBeVisible() }) test('should not show reply button for unauthenticated users', async ({ page }) => { diff --git a/tests/data-setup.spec.ts b/tests/data-setup.spec.ts index 1169f5b3c..73924309c 100644 --- a/tests/data-setup.spec.ts +++ b/tests/data-setup.spec.ts @@ -9,21 +9,17 @@ import { test.describe.serial('Test Data Setup', () => { test.setTimeout(120000) - test('ensure approved handheld listing exists', async ({ browser }) => { + test('ensure handheld listing creation works', async ({ browser }) => { await withContext(browser, 'tests/.auth/user.json', async (page) => { await createHandheldListing(page) }) }) - test('ensure approved PC listing exists', async ({ browser }) => { - // Seed user has trust score 0 and creates pending PC listings, so the - // listing is created as a regular user and then approved as super_admin. + test('ensure approved PC listing exists for list and voting tests', async ({ browser }) => { await ensureApprovedPcListing(browser) }) - test('ensure report exists for admin-reports tests', async ({ browser }) => { - // Reporter must differ from the listing author โ€” the Report button is - // hidden on listings you authored. + test('ensure report exists from a non-author account', async ({ browser }) => { await withContext(browser, 'tests/.auth/author.json', async (page) => { await createReport(page) }) diff --git a/tests/error-handling.spec.ts b/tests/error-handling.spec.ts index c5b2e5309..d1e5c589e 100644 --- a/tests/error-handling.spec.ts +++ b/tests/error-handling.spec.ts @@ -57,8 +57,6 @@ test.describe('Error Handling Tests', () => { '/listings?device=invalid-device-123&emulator=non-existent-emulator&performance=super-invalid', ) - // Page layout has nested
elements, so strict-mode selectors on - // main fail. The heading is a unique non-crash indicator. const listingsPage = new ListingsPage(page) await expect(listingsPage.pageHeading).toBeVisible() }) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 4e43fd08c..59158d963 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -2,8 +2,6 @@ import { test as base } from '@playwright/test' import { registerCookieConsent } from './helpers/cookie-consent' export const test = base.extend({ - // Renamed `use` โ†’ `run` so Next.js's react-hooks/rules-of-hooks lint rule - // does not misclassify `use(page)` as a React Hook call. page: async ({ page }, run) => { await registerCookieConsent(page.context()) await run(page) diff --git a/tests/global.setup.ts b/tests/global.setup.ts index f93263c52..24707d7d7 100644 --- a/tests/global.setup.ts +++ b/tests/global.setup.ts @@ -3,7 +3,6 @@ import { clerkSetup } from '@clerk/testing/playwright' async function globalSetup() { console.log('๐Ÿ”ง Starting global setup for Playwright tests...') - // Official Clerk setup - handles authentication initialization await clerkSetup() console.log('โœ… Global setup completed - Clerk initialized') diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index 8577506db..6efb82dfe 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -1,6 +1,34 @@ import { expect } from '@playwright/test' import { registerCookieConsent } from './cookie-consent' -import type { Browser, Page } from '@playwright/test' +import type { Browser, Locator, Page } from '@playwright/test' + +const VALID_CUSTOM_FIELD_TEXT_VALUE = 'https://example.com' +const PC_LISTING_GAME_SEARCH_TERMS = ['Zelda', 'Mario', 'Metroid', 'Animal', 'Sonic'] as const +const PC_LISTING_CPU_SEARCH_TERMS = [ + 'Core Ultra 9 285K', + 'Ryzen 9 9950X', + 'M4 Max', + 'Core i9-14900K', + 'Ryzen 7 9800X3D', +] as const +const PC_LISTING_GPU_SEARCH_TERMS = [ + undefined, + 'GeForce RTX 5090', + 'GeForce RTX 5080', + 'GeForce RTX 4090', + 'Radeon RX 7900 XTX', + 'Arc B580', +] as const + +const PC_LISTING_CANDIDATES = PC_LISTING_GAME_SEARCH_TERMS.flatMap((gameSearchTerm) => + PC_LISTING_CPU_SEARCH_TERMS.flatMap((cpuSearchTerm) => + PC_LISTING_GPU_SEARCH_TERMS.map((gpuSearchTerm) => ({ + gameSearchTerm, + cpuSearchTerm, + gpuSearchTerm, + })), + ), +) export async function selectAutocompleteOption( page: Page, @@ -19,8 +47,6 @@ export async function selectAutocompleteOption( const options = listbox.locator('[role="option"]') if (searchTerm) { - // Waiting on a filtered match avoids racing the debounced search and - // clicking an unfiltered option before the filter has applied. const matchingOption = options.filter({ hasText: new RegExp(searchTerm, 'i') }) await expect(matchingOption.first()).toBeVisible() await matchingOption.first().click() @@ -32,6 +58,23 @@ export async function selectAutocompleteOption( await expect(listbox).toBeHidden() } +async function isOptionalCombobox(combobox: Locator): Promise { + const containerText = await combobox + .locator('xpath=ancestor::div[position() <= 3]') + .first() + .textContent() + + return /optional/i.test(containerText ?? '') +} + +async function selectFirstComboboxOption(page: Page, combobox: Locator) { + await combobox.click() + const listbox = page.locator('[role="listbox"]') + await expect(listbox).toBeVisible() + await listbox.locator('[role="option"]').first().click() + await expect(listbox).toBeHidden() +} + async function fillEmptySelects(page: Page) { const selects = page.locator('form select') const selectCount = await selects.count() @@ -58,11 +101,6 @@ async function fillEmptySelects(page: Page) { } async function fillRequiredComboboxes(page: Page) { - // Must run before fillEmptyTextInputs โ€” combobox inputs match the generic - // text-input selector otherwise and get stuffed with a URL instead of a - // selected option. Game/device/emulator comboboxes are already replaced - // with cards by the caller, so remaining visible empty comboboxes are - // custom-field autocompletes (e.g., Driver Version for Eden). const comboboxes = page.locator('form [role="combobox"]') const comboboxCount = await comboboxes.count() @@ -73,26 +111,13 @@ async function fillRequiredComboboxes(page: Page) { const value = await combobox.inputValue() if (value && value.length > 0) continue - // Optional comboboxes (e.g., GPU) live inside a section whose label - // contains "Optional" โ€” leave them empty. - const containerText = await combobox - .locator('xpath=ancestor::div[position() <= 3]') - .first() - .textContent() - if (containerText && /optional/i.test(containerText)) continue - - await combobox.click() - const listbox = page.locator('[role="listbox"]') - await expect(listbox).toBeVisible() - await listbox.locator('[role="option"]').first().click() - await expect(listbox).toBeHidden() + if (await isOptionalCombobox(combobox)) continue + + await selectFirstComboboxOption(page, combobox) } } async function fillEmptyTextInputs(page: Page) { - // `https://example.com` is valid for both plain-text and url-typed custom - // fields (e.g., YouTube, Screenshots). A plain word like "test" would pass - // the text fields but fail URL validation on the URL ones. const textInputs = page.locator( 'form input[type="text"]:not([role="combobox"]):not([placeholder*="Search" i]):not([placeholder*="Select for" i]), form input[type="number"], form input[type="url"]', ) @@ -109,15 +134,12 @@ async function fillEmptyTextInputs(page: Page) { if (type === 'number') { await input.fill('1') } else { - await input.fill('https://example.com') + await input.fill(VALID_CUSTOM_FIELD_TEXT_VALUE) } } } async function checkRequiredBooleanFields(page: Page) { - // Required boolean custom fields start as `undefined` and the form validates - // them as missing until explicitly set. Clicking the checkbox converts - // undefined โ†’ true, satisfying the validation. const checkboxes = page.locator('form input[type="checkbox"]') const checkboxCount = await checkboxes.count() @@ -160,12 +182,19 @@ export async function createHandheldListing(page: Page): Promise { await expect(page).toHaveURL(/\/listings(?!\/new)/) } -export async function createPcListing(page: Page): Promise { +async function fillPcListingForm( + page: Page, + candidate: (typeof PC_LISTING_CANDIDATES)[number], +): Promise { await page.goto('/pc-listings/new') await page.waitForLoadState('domcontentloaded') - await selectAutocompleteOption(page, /search for a game/i, 'Zelda') - await selectAutocompleteOption(page, /select a cpu/i, 'Intel') + await selectAutocompleteOption(page, /search for a game/i, candidate.gameSearchTerm) + await selectAutocompleteOption(page, /select a cpu/i, candidate.cpuSearchTerm) + + if (candidate.gpuSearchTerm) { + await selectAutocompleteOption(page, /select a gpu/i, candidate.gpuSearchTerm) + } const memoryInput = page.getByPlaceholder(/e\.g\., 16/i) await expect(memoryInput).toBeVisible() @@ -188,81 +217,87 @@ export async function createPcListing(page: Page): Promise { const notesField = page.getByPlaceholder(/share your experience/i) await expect(notesField).toBeVisible() await notesField.fill('E2E test PC listing') +} + +async function submitPcListingForm(page: Page): Promise<'created' | 'already-exists'> { + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/trpc/pcListings.create') && + response.request().method() === 'POST', + ) await page.getByRole('button', { name: /create compatibility report/i }).click() + const response = await responsePromise + if (response.status() === 409) return 'already-exists' + if (!response.ok()) { + throw new Error(`PC listing creation failed with HTTP ${response.status()}`) + } + await page.waitForURL(/\/pc-listings\/(?!new)[^/?#]+/) - return page.url() + return 'created' } -export async function ensureApprovedPcListing(browser: Browser): Promise { - const existing = await findFirstApprovedPcListing(browser) - if (existing) return existing - - let detailUrl = '' - - await withContext(browser, 'tests/.auth/user.json', async (page) => { - detailUrl = await createPcListing(page) - }) +export async function createPcListing(page: Page): Promise { + for (const candidate of PC_LISTING_CANDIDATES) { + await fillPcListingForm(page, candidate) - await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { - await approveFirstPendingListing(page, '/admin/pc-listing-approvals') - }) + const result = await submitPcListingForm(page) + if (result === 'created') return page.url() + } - return detailUrl + throw new Error('createPcListing: every fixture combination already exists') } -async function findFirstApprovedPcListing(browser: Browser): Promise { - let detailUrl: string | null = null +function getListingIdFromDetailUrl(detailUrl: string): string { + const segments = new URL(detailUrl).pathname.split('/').filter(Boolean) + const listingId = segments[segments.length - 1] - await withContext(browser, 'tests/.auth/user.json', async (page) => { - await page.goto('/pc-listings') - await page.waitForLoadState('domcontentloaded') + if (!listingId) throw new Error(`Could not determine listing id from ${detailUrl}`) - const firstLink = page.locator('tbody tr a[href*="/pc-listings/"]').first() - if (!(await firstLink.isVisible({ timeout: 2000 }).catch(() => false))) return + return listingId +} - const href = await firstLink.getAttribute('href') - if (href) detailUrl = new URL(href, page.url()).href +async function confirmApprovalDialog(page: Page) { + const dialog = page.locator('[role="dialog"]') + await expect(dialog).toBeVisible() + + const confirmButton = dialog.getByRole('button', { + name: /approve anyway|confirm approval/i, }) + await expect(confirmButton).toBeVisible() + await confirmButton.click() - return detailUrl + await expect(dialog).toBeHidden() } -export async function approveFirstPendingListing( - page: Page, - approvalPath: '/admin/approvals' | '/admin/pc-listing-approvals', -): Promise { - await page.goto(approvalPath, { waitUntil: 'domcontentloaded' }) - await expect(page.getByText(/loading/i)).toBeHidden() - - // Handheld buttons have title="Approve Listing", PC has "Approve PC Listing". - const approveButton = page.locator('button[title^="Approve"]').first() +async function approvePendingListingRow(page: Page, row: Locator) { + const approveButton = row.locator('button[title^="Approve"]').first() await expect(approveButton).toBeVisible() - await approveButton.click() - const dialog = page.locator('[role="dialog"]') - await expect(dialog).toBeVisible() - - const confirmBtn = dialog - .locator('button') - .filter({ hasText: /confirm|approve/i }) - .last() - await expect(confirmBtn).toBeVisible() - await confirmBtn.click() - - await expect(dialog).toBeHidden() + await confirmApprovalDialog(page) } -export async function rejectFirstPendingListing( - page: Page, - approvalPath: '/admin/approvals' | '/admin/pc-listing-approvals', -): Promise { - await page.goto(approvalPath, { waitUntil: 'domcontentloaded' }) +export async function approvePendingPcListingByUrl(page: Page, detailUrl: string): Promise { + const listingId = getListingIdFromDetailUrl(detailUrl) + await page.goto('/admin/pc-listing-approvals?sortField=createdAt&sortDirection=desc', { + waitUntil: 'domcontentloaded', + }) await expect(page.getByText(/loading/i)).toBeHidden() - const rejectButton = page.locator('button[title^="Reject"]').first() + const row = page + .locator('tbody tr', { + has: page.locator(`a[href="/pc-listings/${listingId}"]`), + }) + .first() + await expect(row).toBeVisible() + + await approvePendingListingRow(page, row) +} + +async function rejectPendingListingRow(page: Page, row: Locator): Promise { + const rejectButton = row.locator('button[title^="Reject"]').first() await expect(rejectButton).toBeVisible() await rejectButton.click() @@ -284,14 +319,63 @@ export async function rejectFirstPendingListing( await expect(dialog).toBeHidden() } +export async function rejectPendingPcListingByUrl(page: Page, detailUrl: string): Promise { + const listingId = getListingIdFromDetailUrl(detailUrl) + await page.goto('/admin/pc-listing-approvals?sortField=createdAt&sortDirection=desc', { + waitUntil: 'domcontentloaded', + }) + await expect(page.getByText(/loading/i)).toBeHidden() + + const row = page + .locator('tbody tr', { + has: page.locator(`a[href="/pc-listings/${listingId}"]`), + }) + .first() + await expect(row).toBeVisible() + + await rejectPendingListingRow(page, row) +} + +export async function ensureApprovedPcListing(browser: Browser): Promise { + const existing = await findFirstApprovedPcListing(browser) + if (existing) return existing + + let detailUrl = '' + + await withContext(browser, 'tests/.auth/user.json', async (page) => { + detailUrl = await createPcListing(page) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await approvePendingPcListingByUrl(page, detailUrl) + }) + + return detailUrl +} + +async function findFirstApprovedPcListing(browser: Browser): Promise { + let detailUrl: string | null = null + + await withContext(browser, 'tests/.auth/user.json', async (page) => { + await page.goto('/pc-listings') + await page.waitForLoadState('domcontentloaded') + + const firstLink = page.locator('tbody tr a[href*="/pc-listings/"]').first() + if (!(await firstLink.isVisible({ timeout: 2000 }).catch(() => false))) return + + const href = await firstLink.getAttribute('href') + if (href) detailUrl = new URL(href, page.url()).href + }) + + return detailUrl +} + export async function createReport(page: Page): Promise { await page.goto('/listings') const rows = page.locator('table tbody tr') await expect(rows.first()).toBeVisible() - // The Report button only renders for non-author viewers, so we iterate - // over several listings until we find one the current user didn't author. const rowCount = await rows.count() const maxAttempts = Math.min(rowCount, 15) @@ -322,15 +406,15 @@ export async function createReport(page: Page): Promise { const submitBtn = dialog.getByRole('button', { name: /submit report/i }) await submitBtn.click() - // "Already reported" is a valid outcome โ€” the goal is that a report - // exists in the DB, not that this specific run created it. - const alreadyReportedError = dialog.getByText(/already reported/i) - const resolved = await Promise.race([ + const reportOutcome = await Promise.race([ dialog.waitFor({ state: 'hidden' }).then(() => 'success' as const), - alreadyReportedError.waitFor({ state: 'visible' }).then(() => 'already-exists' as const), + dialog + .getByText(/already reported/i) + .waitFor({ state: 'visible' }) + .then(() => 'already-exists' as const), ]) - if (resolved === 'success' || resolved === 'already-exists') return + if (reportOutcome === 'success' || reportOutcome === 'already-exists') return } throw new Error(`createReport: could not find a listing to report after ${maxAttempts} attempts`) @@ -372,13 +456,13 @@ export async function resetUserTrustScore(page: Page, targetUserEmail: string): await dialog.getByRole('button', { name: /trust actions/i }).click() - let remaining = currentScore + let remaining = -currentScore - while (remaining > 0) { - const batch = Math.min(remaining, 1000) + while (remaining !== 0) { + const batch = Math.sign(remaining) * Math.min(Math.abs(remaining), 1000) await dialog.getByLabel('Reason for trust adjustment').fill('E2E: resetting trust score') - await dialog.getByLabel('Custom trust adjustment value').fill(String(-batch)) + await dialog.getByLabel('Custom trust adjustment value').fill(String(batch)) await dialog.getByRole('button', { name: /apply/i }).click() await expect(page.getByText(/trust score adjusted/i)).toBeVisible() diff --git a/tests/listings-success-rate-sorting.spec.ts b/tests/listings-success-rate-sorting.spec.ts index 799a735fe..aa194e82d 100644 --- a/tests/listings-success-rate-sorting.spec.ts +++ b/tests/listings-success-rate-sorting.spec.ts @@ -8,14 +8,10 @@ test.describe('Success Rate Sorting', () => { await page.goto('/listings') await page.waitForLoadState('domcontentloaded') - // The community support banner overlays the table header and intercepts - // sort clicks if it's still present. const dismissBanner = page.getByRole('button', { name: /dismiss community support/i }) - try { - await dismissBanner.waitFor({ state: 'visible', timeout: 1000 }) + + if (await dismissBanner.isVisible({ timeout: 1000 }).catch(() => false)) { await dismissBanner.click() - } catch { - // Banner not present โ€” nothing to dismiss } }) diff --git a/tests/pages/AuthPage.ts b/tests/pages/AuthPage.ts index 06757877d..6d22babe7 100644 --- a/tests/pages/AuthPage.ts +++ b/tests/pages/AuthPage.ts @@ -7,7 +7,6 @@ export class AuthPage extends BasePage { } get userButton() { - // Clerk's UserButton renders with aria-label "Open user menu" return this.page.getByRole('button', { name: /open user menu/i }) } @@ -16,9 +15,6 @@ export class AuthPage extends BasePage { } async verifyAuthRequired() { - // The app may either show an auth required message or redirect to a - // non-protected page. Both are valid signs of auth enforcement. - // Asserting the sign in button is visible means the user is not logged in. await expect(this.signInButton).toBeVisible() } } diff --git a/tests/pages/GamesPage.ts b/tests/pages/GamesPage.ts index 7bd44cebd..24ee70b4b 100644 --- a/tests/pages/GamesPage.ts +++ b/tests/pages/GamesPage.ts @@ -16,7 +16,6 @@ export class GamesPage extends BasePage { } get addGameButton() { - // On the /games library page, "Add Game" is an anchor link to /games/new. return this.page.getByRole('link', { name: /add game/i }) } diff --git a/tests/pages/ListingsPage.ts b/tests/pages/ListingsPage.ts index 63b7c17be..deb7ead00 100644 --- a/tests/pages/ListingsPage.ts +++ b/tests/pages/ListingsPage.ts @@ -31,8 +31,6 @@ export class ListingsPage extends BasePage { return this.page.getByText(/no listings found|no results|empty|nothing found/i) } - // Filters are MultiSelect components rendered as buttons with aria-label - // like "Devices multi-select", "Emulators multi-select", etc. get deviceFilter() { return this.page.getByRole('button', { name: /devices multi-select/i }) } @@ -50,8 +48,6 @@ export class ListingsPage extends BasePage { } get clearFiltersButton() { - // The Active Filters section renders a "Clear All" button when filters - // are applied (scoped to avoid "Clear all selections" per-field buttons). return this.page.getByRole('button', { name: /^clear all$/i }) } diff --git a/tests/pagination.spec.ts b/tests/pagination.spec.ts index ed6339e5e..b44e21402 100644 --- a/tests/pagination.spec.ts +++ b/tests/pagination.spec.ts @@ -3,11 +3,7 @@ import { GamesPage } from './pages/GamesPage' import { ListingsPage } from './pages/ListingsPage' import type { Page } from '@playwright/test' -// The Pagination component renders its navigation buttons twice in the same -//