fix playwright tests (#759)

This commit is contained in:
slothful-vassal
2026-02-01 21:02:36 +01:00
committed by GitHub
parent 029f1f134b
commit 6d5494935d
8 changed files with 90 additions and 42 deletions

View File

@@ -11,8 +11,8 @@ import { defineConfig, devices } from '@playwright/test';
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests/playwright', testDir: './tests/playwright',
/* Run tests in files in parallel */ /* Run tests in files in parallel - disabled to prevent auth conflicts */
fullyParallel: true, fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
@@ -34,8 +34,10 @@ export default defineConfig({
projects: [ projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'setup', testMatch: /.*\.setup\.ts/ },
{ {
name: 'teardown', testMatch: /.*\.teardown\.ts/, use: name: 'teardown',
{ testMatch: /.*\.teardown\.ts/,
dependencies: ['chromium'],
use: {
storageState: 'playwright/.auth/user.json', storageState: 'playwright/.auth/user.json',
} }
}, },
@@ -52,7 +54,7 @@ export default defineConfig({
{ {
name: 'chromium', name: 'chromium',
// dependencies: ['setup'], dependencies: ['setup'],
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json', storageState: 'playwright/.auth/user.json',

View File

@@ -38,7 +38,7 @@
: $theme === "light" : $theme === "light"
? emptyStateTrailLight ? emptyStateTrailLight
: emptyStateTrailDark} : emptyStateTrailDark}
alt="avatar" alt="list avatar"
/> />
<div class="self-start min-w-0 basis-full transition-transform"> <div class="self-start min-w-0 basis-full transition-transform">
@@ -67,7 +67,7 @@
class="rounded-full w-5 aspect-square mx-1 inline" class="rounded-full w-5 aspect-square mx-1 inline"
src={list.expand.author.icon || src={list.expand.author.icon ||
`https://api.dicebear.com/7.x/initials/svg?seed=${list.expand.author.preferred_username}&backgroundType=gradientLinear`} `https://api.dicebear.com/7.x/initials/svg?seed=${list.expand.author.preferred_username}&backgroundType=gradientLinear`}
alt="avatar" alt="author avatar"
/> />
{handleFromRecordWithIRI(list)} {handleFromRecordWithIRI(list)}
</p> </p>

View File

@@ -251,6 +251,7 @@
{#snippet children({ toggleMenu: openDropdown })} {#snippet children({ toggleMenu: openDropdown })}
<div class="flex items-center"> <div class="flex items-center">
<button <button
aria-label="Open user menu"
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square" class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 aspect-square"
onclick={openDropdown} onclick={openDropdown}
> >

View File

@@ -3,6 +3,9 @@ import { test as teardown } from '@playwright/test';
teardown('delete user', async ({ page }) => { teardown('delete user', async ({ page }) => {
await page.goto('/settings/account', { waitUntil: 'networkidle' }); await page.goto('/settings/account', { waitUntil: 'networkidle' });
await page.locator("#delete-account").click(); await page.locator("#delete-account").click();
// Wait for modal to appear
await page.locator("#confirm").waitFor({ state: 'visible' });
await page.locator("#confirm").click(); await page.locator("#confirm").click();
await page.waitForURL('/'); await page.waitForURL('/');

View File

@@ -1,30 +1,39 @@
import { test as base, expect } from '@playwright/test'; import { test as base, expect } from '@playwright/test';
import { ListsPage } from '../../pages/lists_page'; import { ListsPage } from '../../pages/lists_page';
// Run tests serially to avoid race conditions with shared list data
base.describe.configure({ mode: 'serial' });
const test = base.extend<{ listsPage: ListsPage }>({ const test = base.extend<{ listsPage: ListsPage }>({
listsPage: async ({ page }, use) => { listsPage: async ({ page }, use) => {
const listsPage = new ListsPage(page); const listsPage = new ListsPage(page);
await listsPage.goto(); await listsPage.goto();
await listsPage.create();
await use(listsPage); await use(listsPage);
await listsPage.removeAll(); await listsPage.removeAll();
}, },
}); });
test('shows a list card', async ({ listsPage }) => { test('shows a list card', async ({ listsPage }) => {
const testListName = `Test List ${Date.now()}`;
const listItemCount = await listsPage.listItems.count(); const listItemCount = await listsPage.listItems.count();
await listsPage.create(); await listsPage.create(testListName);
expect(listsPage.listItems).toHaveCount(listItemCount + 1);
expect(listsPage.listItemsImage).toBeVisible(); await expect(listsPage.listItems).toHaveCount(listItemCount + 1);
const createdListItem = listsPage.listItems.filter({ hasText: testListName });
await expect(createdListItem).toBeVisible();
await expect(createdListItem.getByRole('img', { name: 'list avatar' })).toBeVisible();
}); });
test('update a list card', async ({ listsPage }) => { test('update a list card', async ({ listsPage }) => {
await listsPage.create("List to Update");
await listsPage.update(); await listsPage.update();
expect(listsPage.listItems.first()).toContainText("Updated List"); await expect(listsPage.listItems.first()).toContainText("Updated List");
expect(listsPage.listItems.first()).toContainText("New Description"); await expect(listsPage.listItems.first()).toContainText("New Description");
}); });
test('delete a list card', async ({ listsPage }) => { test('delete a list card', async ({ listsPage }) => {
await listsPage.create("List to Delete");
const countBefore = await listsPage.listItems.count();
await listsPage.delete(); await listsPage.delete();
expect(listsPage.listItems).toHaveCount(0); await expect(listsPage.listItems).toHaveCount(countBefore - 1);
}); });

View File

@@ -1,10 +1,12 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('logs the user out', async ({ page }) => { test('logs the user out', async ({ page }) => {
await page.goto('/'); await page.goto('/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'avatar' }).click(); await page.getByLabel('Open user menu').click();
await page.locator(".menu .menu-item").filter({ hasText: "Logout" }).click(); await page.locator(".menu .menu-item").filter({ hasText: "Logout" }).click();
await page.waitForURL('/');
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const pbAuthCookie = cookies.find(cookie => cookie.name === 'pb_auth'); const pbAuthCookie = cookies.find(cookie => cookie.name === 'pb_auth');
expect(pbAuthCookie).toBeFalsy(); expect(pbAuthCookie).toBeFalsy();

View File

@@ -10,14 +10,20 @@ export class IndexPage {
} }
async goto() { async goto() {
await this.page.goto('/'); await this.page.goto('/', { waitUntil: 'networkidle' });
} }
async search() { async search() {
// Start waiting for response before triggering the search
const responsePromise = this.page.waitForResponse(resp =>
resp.url().includes('/api/v1/search/multi') && resp.status() === 200
);
await this.page.locator('input[name="q"]').fill('Munich'); await this.page.locator('input[name="q"]').fill('Munich');
await this.page.waitForResponse('**/api/v1/search/multi'); await responsePromise;
await this.page.locator('.menu-item').first().click(); await this.page.locator('.menu-item').first().click();
await this.page.waitForURL('/map?lat=48.13743&lon=11.57549'); await this.page.waitForURL(/\/map\?lat=.*&lon=.*/);
} }

View File

@@ -7,12 +7,13 @@ export class ListsPage {
readonly listItems: Locator; readonly listItems: Locator;
readonly listItemsImage: Locator; readonly listItemsImage: Locator;
readonly listModal: Locator; readonly listForm: Locator;
readonly listModalAvatar: Locator; readonly listFormAvatar: Locator;
readonly listModalName: Locator; readonly listFormName: Locator;
readonly listModalDescription: Locator; readonly listFormDescription: Locator;
readonly listModalSaveButton: Locator; readonly listFormSaveButton: Locator;
readonly dropdownButton: Locator;
readonly confirmModal: Locator; readonly confirmModal: Locator;
readonly confirmModalConfirmButton: Locator; readonly confirmModalConfirmButton: Locator;
@@ -20,17 +21,19 @@ export class ListsPage {
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.createListButton = page.locator("#create-list"); this.createListButton = page.getByLabel('New list');
this.listModal = page.locator("#list-modal"); this.listForm = page.locator("#list-form");
this.listModalAvatar = this.listModal.locator('input[name="avatar"]') this.listFormAvatar = page.locator('#avatar')
this.listModalName = this.listModal.locator('input[name="name"]') this.listFormName = page.locator('input[name="name"]')
this.listModalDescription = this.listModal.locator('textarea[name="description"]') this.listFormDescription = page.locator('.ProseMirror')
this.listModalSaveButton = this.listModal.getByText('Save'); this.listFormSaveButton = this.listForm.locator('button[type="submit"]');
this.listItems = page.locator('.list-list-item'); this.listItems = page.locator('.list-list-item');
this.listItemsImage = page.locator('.list-list-item img'); // Only match the main avatar image (first img with aspect-square class in each list item)
this.listItemsImage = page.locator('.list-list-item img.aspect-square');
this.dropdownButton = page.getByLabel('Open dropdown');
this.confirmModal = page.locator("#confirm-modal"); this.confirmModal = page.locator("#confirm-modal");
this.confirmModalConfirmButton = this.confirmModal.locator("button").filter({ hasText: "Delete" }); this.confirmModalConfirmButton = this.confirmModal.locator("button").filter({ hasText: "Delete" });
@@ -40,33 +43,49 @@ export class ListsPage {
await this.page.goto('/lists', { waitUntil: 'networkidle' }); await this.page.goto('/lists', { waitUntil: 'networkidle' });
} }
private async selectDropdownAction(action: string) {
await this.dropdownButton.click();
await this.page.locator(".menu .menu-item").filter({ hasText: action }).click();
}
async create(name: string = "Test List") { async create(name: string = "Test List") {
await this.createListButton.click(); await this.createListButton.click();
await this.listModalName.fill(name); await this.listFormName.fill(name);
await this.listModalAvatar.setInputFiles([ await this.listFormAvatar.setInputFiles([
"./tests/playwright/fixtures/avatar.webp" "./tests/playwright/fixtures/avatar.webp"
]); ]);
await Promise.all([ await Promise.all([
this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200), this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200),
this.listModalSaveButton.click() this.listFormSaveButton.click()
]); ]);
// Navigate back to lists page and wait for list items to load
await this.page.goto('/lists', { waitUntil: 'domcontentloaded' });
await this.listItems.first().waitFor({ state: 'visible', timeout: 10000 });
} }
async update(name: string = "Updated List", description = "New Description") { async update(name: string = "Updated List", description = "New Description") {
await this.listItems.first().locator(".dropdown button").click(); await this.listItems.first().click();
await this.listItems.first().locator(".menu .menu-item").filter({ hasText: "Edit" }).click(); await this.selectDropdownAction("Edit");
await this.listModalName.fill(name);
await this.listModalDescription.fill(description); await this.listFormName.clear();
await this.listFormName.fill(name);
await this.listFormDescription.fill(description);
await Promise.all([ await Promise.all([
this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200), this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200),
this.listModalSaveButton.click() this.listFormSaveButton.click()
]); ]);
// Navigate back to lists page
await this.page.goto('/lists', { waitUntil: 'domcontentloaded' });
} }
async delete() { async delete() {
await this.listItems.first().locator(".dropdown button").click(); await this.listItems.first().click();
await this.listItems.first().locator(".menu .menu-item").filter({ hasText: "Delete" }).click(); await this.selectDropdownAction("Delete");
await Promise.all([ await Promise.all([
this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200), this.page.waitForResponse(resp => resp.url().includes('/api/v1/list') && resp.status() === 200),
this.confirmModalConfirmButton.click() this.confirmModalConfirmButton.click()
@@ -74,8 +93,14 @@ export class ListsPage {
} }
async removeAll() { async removeAll() {
while ((await this.listItems.count()) > 0) { await this.goto();
let count = await this.listItems.count();
while (count > 0) {
await this.delete(); await this.delete();
// Navigate back to lists page after deletion
await this.goto();
count = await this.listItems.count();
} }
} }