Repository: besessener/ProcrastinationBuddy
Pull request #19: Refactor Playwright tests to make them more maintainable
Author: login: csvtuda
Created: 2025-05-22T13:11:15Z | Merged: 2025-05-23T11:06:06Z

## Summary

The Playwright E2E tests are a little bit messy. This PR makes them more maintainable and easier to follow ([look at that beauty!](https://github.com/besessener/ProcrastinationBuddy/blob/4e80914c336a990727837e03a0d8f2487e082181/tests/e2e/tests/procrastination.spec.ts)).

## Changes Made

- removed global locators and page variables
- removed unnecessary `clickWithDelay` and `fillWithDelay` functions
- added page objects
- added page object fixture

## Related Issue

- #15 



Diff:
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
index 38d97cc..b6e98ce 100644
--- a/tests/e2e/playwright.config.ts
+++ b/tests/e2e/playwright.config.ts
@@ -8,8 +8,9 @@ export default defineConfig({
     workers: 1,
     reporter: [["junit", { outputFile: "test-results/results.xml" }], ["html"], ["list"]],
     timeout: 600_000,
-    expect: { timeout: 10_000 }, 
+    expect: { timeout: 10_000 },
     use: {
+        actionTimeout: 10_000,
         trace: "retain-on-failure",
         screenshot: "only-on-failure",
     },
diff --git a/tests/e2e/tests/info-modal.ts b/tests/e2e/tests/info-modal.ts
new file mode 100644
index 0000000..e3e4f5a
--- /dev/null
+++ b/tests/e2e/tests/info-modal.ts
@@ -0,0 +1,40 @@
+import { Locator, Page } from '@playwright/test';
+
+interface InfoModal {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
+    close(): Promise<void>;
+  };
+  /**
+   * Raw locators.
+   */
+  locators: {
+    buttons: {
+      close: Locator;
+    };
+  };
+}
+
+export function initInfoModal(page: Page): InfoModal {
+  const root = page.getByRole('dialog');
+  const locators = {
+    buttons: {
+      close: page
+        .getByRole('dialog')
+        .getByRole('button', { name: 'Close' })
+        .nth(1),
+    },
+  };
+  const interactions = {
+    close: async () => {
+      await locators.buttons.close.click();
+    },
+  };
+  return Object.assign(() => root, { locators, do: interactions });
+}
diff --git a/tests/e2e/tests/procrastination-fixture.ts b/tests/e2e/tests/procrastination-fixture.ts
new file mode 100644
index 0000000..19f0040
--- /dev/null
+++ b/tests/e2e/tests/procrastination-fixture.ts
@@ -0,0 +1,34 @@
+import test, { Page } from '@playwright/test';
+import { initInfoModal } from './info-modal';
+import { initProcrastinationPage } from './procrastination-page';
+import { initSettingsModal } from './settings-modal';
+
+function createPageTree(page: Page) {
+  // We use getters here for lazy evaluation. There's no need to create all page objects if we're
+  // just interested in a single one.
+  return {
+    get main() {
+      return initProcrastinationPage(page);
+    },
+    get modal() {
+      return {
+        get info() {
+          return initInfoModal(page);
+        },
+        get settings() {
+          return initSettingsModal(page);
+        },
+      };
+    },
+  };
+}
+
+export const buddyTest = test.extend<{
+  on: typeof createPageTree;
+}>({
+  on: async ({}, use) => {
+    await use((page: Page) => {
+      return createPageTree(page);
+    });
+  },
+});
diff --git a/tests/e2e/tests/procrastination-page.ts b/tests/e2e/tests/procrastination-page.ts
new file mode 100644
index 0000000..64305a6
--- /dev/null
+++ b/tests/e2e/tests/procrastination-page.ts
@@ -0,0 +1,97 @@
+import { expect, Locator, Page } from '@playwright/test';
+
+interface ProcrastinationPage {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
+    filterTasks(filter: { onlyLiked?: boolean }): Promise<void>;
+    generateTask(): Promise<void>;
+    likeTask(filter: number): Promise<void>;
+    openInfo(): Promise<void>;
+    openSettings(): Promise<void>;
+  };
+  /**
+   * Raw locators.
+   */
+  locators: {
+    buttons: {
+      generate: Locator;
+      info: Locator;
+      like: Locator;
+      settings: Locator;
+    };
+    heading: Locator;
+    switches: {
+      filterLikes: Locator;
+    };
+    texts: {
+      noTasks: Locator;
+    };
+    spinners: {
+      generatingTask: Locator;
+    };
+    tasks: Locator;
+  };
+}
+
+export function initProcrastinationPage(page: Page): ProcrastinationPage {
+  const root = page.locator('body');
+  const locators = {
+    buttons: {
+      generate: root.getByRole('button', { name: 'Generate' }),
+      info: root.getByRole('button', { name: 'ℹ️' }),
+      like: root.getByTestId('stIconEmoji').filter({ hasText: '❤' }),
+      settings: root.getByRole('button', { name: '⚙️' }),
+    },
+    heading: root.getByRole('heading', {
+      name: 'Your partner in crime for finding perfectly pointless tasks!',
+    }),
+    switches: {
+      filterLikes: root
+        .getByTestId('stCheckbox')
+        .filter({ hasText: 'Filter Likes' }),
+    },
+    texts: {
+      noTasks: root.getByText('No tasks to display.'),
+    },
+    spinners: {
+      generatingTask: root.getByTestId('stSpinner'),
+    },
+    tasks: root.getByText(/^\d{2}:\d{2}:\d{2}: .+$/),
+  };
+  const interactions = {
+    filterTasks: async (filter: { onlyLiked?: boolean }) => {
+      if (filter.onlyLiked !== undefined) {
+        // Streamlit hides the actual checkbox input, we must include hidden elements.
+        const isChecked = await locators.switches.filterLikes
+          .getByRole('checkbox', { includeHidden: true })
+          .isChecked();
+        if (filter.onlyLiked !== isChecked) {
+          await locators.switches.filterLikes.click();
+        }
+      }
+    },
+    generateTask: async () => {
+      await locators.buttons.generate.click();
+      await expect(locators.spinners.generatingTask).toBeVisible();
+      await expect(locators.spinners.generatingTask).toBeHidden({
+        timeout: 300_000,
+      });
+    },
+    likeTask: async (filter: number) => {
+      await locators.buttons.like.nth(filter).click();
+    },
+    openInfo: async () => {
+      await locators.buttons.info.click();
+    },
+    openSettings: async () => {
+      await locators.buttons.settings.click();
+    },
+  };
+  return Object.assign(() => root, { locators, do: interactions });
+}
diff --git a/tests/e2e/tests/procrastination.spec.ts b/tests/e2e/tests/procrastination.spec.ts
index 1655a38..5fabe40 100644
--- a/tests/e2e/tests/procrastination.spec.ts
+++ b/tests/e2e/tests/procrastination.spec.ts
@@ -1,213 +1,133 @@
-import { test, expect, Page, Locator } from '@playwright/test';
-
-test.describe('Procrastination Buddy UI', () => {
-  let page: Page;
-  const locators = {} as {
-    heading: Locator;
-    generateButton: Locator;
-    infoButton: Locator;
-    settingsButton: Locator;
-    filterLikesLabel: Locator;
-    noTasksText: Locator;
-    spinner: Locator;
-    likeButton: Locator;
-    tasks: Locator;
-    trashButton: Locator;
-    saveButton: Locator;
-    keepFavoritesCheckbox: Locator;
-  };
-
-  const clickWithDelay = async (element: Locator, delayMs = 500) => {
-    await expect(element).toHaveCount(1);
-    await element.click();
-    await element.page().waitForTimeout(delayMs);
-  };
-
-  const fillWithDelay = async (element: Locator, value: string, delayMs = 500) => {
-    await expect(element).toHaveCount(1);
-    await element.fill(value);
-    await element.press('Enter');
-    await element.page().waitForTimeout(delayMs);
-  };
-
-  const defineLocators = (p: Page) => {
-    locators.heading = p.getByRole('heading', {
-      name: 'Your partner in crime for finding perfectly pointless tasks!',
-    });
-    locators.generateButton = p.getByRole('button', { name: 'Generate' });
-    locators.infoButton = p.getByRole('button', { name: 'ℹ️' });
-    locators.settingsButton = p.getByRole('button', { name: '⚙️' });
-    locators.filterLikesLabel = p.getByText('Filter Likes');
-    locators.noTasksText = p.getByText('No tasks to display.');
-    locators.spinner = p.locator('[data-testid="stSpinner"]');
-    locators.likeButton = p.getByTestId('stIconEmoji').filter({ hasText: '❤' });
-    locators.tasks = p.locator('text=/^\\d{2}:\\d{2}:\\d{2}: .+$/');
-    locators.trashButton = p.getByRole('button', { name: '🗑️' });
-    locators.saveButton = p.getByRole('button', {
-      name: /^(Save|Speichern|Guardar|Enregistrer)$/,
-    });
-    locators.keepFavoritesCheckbox = p.getByText(
-      /^(Keep favorites|Favoriten behalten|Mantener favoritos|Conserver les favoris)$/
-    );
-  };
-
-  const resetAppState = async () => {
-    await clickWithDelay(locators.settingsButton);
-    const modal = page.getByRole('dialog');
-    await expect(modal).toBeVisible();
-    await expect(modal).toHaveText(/Tweak Buddy/);
-    await fillWithDelay(page.getByRole('combobox').nth(0), 'English');
-    await fillWithDelay(page.getByRole('combobox').nth(1), 'Europe/Berlin');
-    await fillWithDelay(page.getByRole('combobox').nth(2), 'llama3:8b');
-    await fillWithDelay(page.getByRole('combobox').nth(3), '5');
-    await clickWithDelay(locators.keepFavoritesCheckbox);
-    await clickWithDelay(locators.trashButton);
-    await expect(locators.spinner).toBeVisible();
-    await expect(locators.spinner).toBeHidden();
-    await clickWithDelay(locators.saveButton);
-    await expect(locators.noTasksText).toBeVisible();
-  };
-
-  test.beforeEach(async ({ page: p }) => {
-    page = p;
+import { expect } from '@playwright/test';
+import { buddyTest } from './procrastination-fixture';
+
+buddyTest.describe('Procrastination Buddy UI', () => {
+  buddyTest.beforeEach(async ({ on, page }) => {
     await page.goto('http://localhost:8501');
-    defineLocators(page);
-    await resetAppState();
+    await on(page).main.do.openSettings();
+    await expect(on(page).modal.settings()).toBeVisible();
+    await expect(on(page).modal.settings()).toHaveText(/Tweak Buddy/);
+    await on(page).modal.settings.do.chooseLanguage('English');
+    await on(page).modal.settings.do.chooseTimezone('Europe/Berlin');
+    await on(page).modal.settings.do.chooseModel('llama3:8b');
+    await on(page).modal.settings.do.chooseTasksPerPage('5');
+    await on(page).modal.settings.do.uncheckFavorites();
+    await on(page).modal.settings.do.trashTasks();
+    await on(page).modal.settings.do.save();
+    await expect(on(page).main.locators.texts.noTasks).toBeVisible();
   });
 
-  test('should have the correct title', async () => {
+  buddyTest('should have the correct title', async ({ page }) => {
     await expect(page).toHaveTitle(/Procrastination Buddy ⏰🤷/);
   });
 
-  test('should display all key UI elements', async () => {
-    const {
-      heading,
-      generateButton,
-      infoButton,
-      settingsButton,
-      filterLikesLabel,
-    } = locators;
-
-    await expect(heading).toBeVisible();
-    await expect(generateButton).toBeVisible();
-    await expect(infoButton).toBeVisible();
-    await expect(settingsButton).toBeVisible();
-    await expect(filterLikesLabel).toBeVisible();
+  buddyTest('should display all key UI elements', async ({ on, page }) => {
+    await expect(on(page).main.locators.heading).toBeVisible();
+    await expect(on(page).main.locators.buttons.generate).toBeVisible();
+    await expect(on(page).main.locators.buttons.info).toBeVisible();
+    await expect(on(page).main.locators.buttons.settings).toBeVisible();
+    await expect(on(page).main.locators.switches.filterLikes).toBeVisible();
   });
 
-  test('should display the info modal', async () => {
-    await clickWithDelay(locators.infoButton);
-    const modal = page.getByRole('dialog');
-    await expect(modal).toBeVisible();
-    await expect(modal).toHaveText(/Explanation Buddy/);
-    await clickWithDelay(modal.getByRole('button', { name: 'Close' }).nth(1));
-    await expect(modal).toBeHidden();
+  buddyTest('should display the info modal', async ({ on, page }) => {
+    await on(page).main.do.openInfo();
+    await expect(on(page).modal.info()).toBeVisible();
+    await expect(on(page).modal.info()).toHaveText(/Explanation Buddy/);
+    await on(page).modal.info.do.close();
+    await expect(on(page).modal.info()).toBeHidden();
   });
 
-  test('should display settings modal', async () => {
-    await clickWithDelay(locators.settingsButton);
-    const modal = page.getByRole('dialog');
-    await expect(modal).toBeVisible();
-    await expect(modal).toHaveText(/Tweak Buddy/);
-    await clickWithDelay(modal.getByRole('button', { name: 'Close' }));
-    await expect(modal).toBeHidden();
+  buddyTest('should display settings modal', async ({ on, page }) => {
+    await on(page).main.do.openSettings();
+    await expect(on(page).modal.settings()).toBeVisible();
+    await expect(on(page).modal.settings()).toHaveText(/Tweak Buddy/);
+    await on(page).modal.settings.do.close();
+    await expect(on(page).modal.settings()).toBeHidden();
   });
 
-  test('should change language', async () => {
-    await clickWithDelay(locators.settingsButton);
-    const modal = page.getByRole('dialog');
-    await fillWithDelay(page.getByRole('combobox').nth(0), 'Deutsch');
-    await clickWithDelay(locators.saveButton);
-    await expect(modal).toBeHidden();
+  buddyTest('should change language', async ({ on, page }) => {
+    await on(page).main.do.openSettings();
+    await on(page).modal.settings.do.chooseLanguage('Deutsch');
+    await on(page).modal.settings.do.save();
+    await expect(on(page).modal.settings()).toBeHidden();
     await expect(page.getByText(/Dein Komplize/)).toBeVisible();
   });
 
-  test('should change timezone and update task timestamp', async () => {
-    const { tasks } = locators;
-    await clickWithDelay(locators.generateButton);
-    await expect(locators.spinner).toBeHidden({ timeout: 300_000 });
-    const taskText1 = await tasks.nth(0).textContent();
-
-    await clickWithDelay(locators.settingsButton);
-    await fillWithDelay(page.getByRole('combobox').nth(1), 'America/New_York');
-    await clickWithDelay(locators.saveButton);
-
-    const taskText2 = await tasks.nth(0).textContent();
-    expect(taskText1).not.toEqual(taskText2);
-  });
+  buddyTest('should change timezone and update task timestamp', async ({ on, page }) => {
+      await on(page).main.do.generateTask();
+      const task = on(page).main.locators.tasks.nth(0);
+      const taskText1 = await task.innerText();
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.chooseTimezone('America/New_York');
+      await on(page).modal.settings.do.save();
+      await expect(task).not.toHaveText(taskText1);
+    }
+  );
 
-  test('should generate a task', async () => {
-    await expect(locators.noTasksText).toBeVisible();
-    await clickWithDelay(locators.generateButton);
-    await expect(locators.spinner).toBeHidden({ timeout: 300_000 });
-    await expect(locators.tasks.nth(0)).toBeVisible();
+  buddyTest('should generate a task', async ({ on, page }) => {
+    await expect(on(page).main.locators.texts.noTasks).toBeVisible();
+    await on(page).main.do.generateTask();
+    await expect(on(page).main.locators.tasks).toHaveCount(1);
   });
 
-  test('should filter liked tasks', async () => {
-    const { tasks, filterLikesLabel, likeButton } = locators;
-
+  buddyTest('should filter liked tasks', async ({ on, page }) => {
     for (let i = 0; i < 2; i++) {
-      await clickWithDelay(locators.generateButton);
-      await expect(locators.spinner).toBeHidden({ timeout: 300_000 });
+      await on(page).main.do.generateTask();
     }
 
-    await clickWithDelay(filterLikesLabel);
-    await expect(tasks).toHaveCount(0);
-
-    await clickWithDelay(filterLikesLabel);
-    await clickWithDelay(likeButton.nth(0));
-    await clickWithDelay(filterLikesLabel);
-    await expect(tasks).toHaveCount(1);
+    await on(page).main.do.filterTasks({ onlyLiked: false });
+    await expect(on(page).main.locators.tasks).toHaveCount(2);
+    await on(page).main.do.likeTask(0);
+    await on(page).main.do.filterTasks({ onlyLiked: true });
+    await expect(on(page).main.locators.tasks).toHaveCount(1);
   });
 
-  test('should not delete liked tasks if preference is set', async () => {
-    await clickWithDelay(locators.generateButton);
-    await expect(locators.tasks).toHaveCount(1);
-    await clickWithDelay(locators.likeButton.nth(0));
-
-    await clickWithDelay(locators.settingsButton);
-    await clickWithDelay(locators.trashButton);
-    await expect(locators.spinner).toBeHidden();
-    await clickWithDelay(locators.saveButton);
-    await expect(locators.tasks).toHaveCount(1);
-
-    await clickWithDelay(locators.likeButton.nth(0));
-    await clickWithDelay(locators.settingsButton);
-    await clickWithDelay(locators.keepFavoritesCheckbox);
-    await clickWithDelay(locators.trashButton);
-    await expect(locators.spinner).toBeHidden();
-    await clickWithDelay(locators.saveButton);
-    await expect(locators.tasks).toHaveCount(0);
-  });
+  buddyTest('should not delete liked tasks if preference is set', async ({ on, page }) => {
+      await on(page).main.do.generateTask();
+      await expect(on(page).main.locators.tasks).toHaveCount(1);
+      await on(page).main.do.likeTask(0);
+
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.trashTasks();
+      await on(page).modal.settings.do.save();
+      await expect(on(page).main.locators.tasks).toHaveCount(1);
+
+      await on(page).main.do.likeTask(0);
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.uncheckFavorites();
+      await on(page).modal.settings.do.trashTasks();
+      await on(page).modal.settings.do.save();
+      await expect(on(page).main.locators.tasks).toHaveCount(0);
+    }
+  );
 
-  test('should show pagination for more than 5 tasks', async () => {
-    await clickWithDelay(locators.settingsButton);
-    await fillWithDelay(page.getByRole('combobox').nth(3), '5');
-    await clickWithDelay(locators.saveButton);
+  buddyTest('should show pagination for more than 5 tasks', async ({ on, page }) => {
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.chooseTasksPerPage('5');
+      await on(page).modal.settings.do.save();
 
-    for (let i = 0; i < 9; i++) {
-      await clickWithDelay(locators.generateButton);
-      await expect(locators.spinner).toBeHidden({ timeout: 300_000 });
-    }
+      for (let i = 0; i < 9; i++) {
+        await on(page).main.do.generateTask();
+      }
 
-    await expect(page.getByRole('button', { name: '2' })).toBeVisible();
-    await clickWithDelay(page.getByRole('button', { name: '2' }));
-    await expect(locators.tasks).toHaveCount(4);
+      await expect(page.getByRole('button', { name: '2' })).toBeVisible();
+      await page.getByRole('button', { name: '2' }).click();
+      await expect(on(page).main.locators.tasks).toHaveCount(4);
 
-    await clickWithDelay(page.getByRole('button', { name: '1' }));
-    await expect(locators.tasks).toHaveCount(5);
+      await page.getByRole('button', { name: '1' }).click();
+      await expect(on(page).main.locators.tasks).toHaveCount(5);
 
-    await clickWithDelay(locators.settingsButton);
-    await fillWithDelay(page.getByRole('combobox').nth(3), '3');
-    await clickWithDelay(locators.saveButton);
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.chooseTasksPerPage('3');
+      await on(page).modal.settings.do.save();
 
-    await expect(page.getByRole('button', { name: '3' })).toBeVisible();
+      await expect(page.getByRole('button', { name: '3' })).toBeVisible();
 
-    await clickWithDelay(locators.settingsButton);
-    await clickWithDelay(locators.trashButton);
-    await expect(locators.spinner).toBeHidden();
-    await clickWithDelay(locators.saveButton);
+      await on(page).main.do.openSettings();
+      await on(page).modal.settings.do.trashTasks();
+      await on(page).modal.settings.do.save();
 
-    await expect(locators.noTasksText).toBeVisible();
-  });
+      await expect(on(page).main.locators.texts.noTasks).toBeVisible();
+    }
+  );
 });
diff --git a/tests/e2e/tests/settings-modal.ts b/tests/e2e/tests/settings-modal.ts
new file mode 100644
index 0000000..6e85d65
--- /dev/null
+++ b/tests/e2e/tests/settings-modal.ts
@@ -0,0 +1,103 @@
+import { expect, Locator, Page } from '@playwright/test';
+
+interface SettingsModal {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
+    chooseLanguage(language: string): Promise<void>;
+    chooseTimezone(timezone: string): Promise<void>;
+    chooseModel(model: string): Promise<void>;
+    chooseTasksPerPage(taskPerPage: string): Promise<void>;
+    close(): Promise<void>;
+    save(): Promise<void>;
+    trashTasks(): Promise<void>;
+    uncheckFavorites(): Promise<void>;
+  };
+  /**
+   * Raw locators.
+   */
+  locators: {
+    checkboxes: {
+      keepFavorites: Locator;
+    };
+    comboboxes: {
+      language: Locator;
+      timezone: Locator;
+      model: Locator;
+      tasksPerPage: Locator;
+    };
+    buttons: {
+      close: Locator;
+      save: Locator;
+      trash: Locator;
+    };
+    spinners: {
+      deletingTasks: Locator;
+    }
+  };
+}
+
+export function initSettingsModal(page: Page): SettingsModal {
+  const root = page.getByRole('dialog');
+  const locators = {
+    checkboxes: {
+      keepFavorites: root.getByText(
+        /^(Keep favorites|Favoriten behalten|Mantener favoritos|Conserver les favoris)$/
+      ),
+    },
+    comboboxes: {
+      language: root.getByRole('combobox').nth(0),
+      timezone: root.getByRole('combobox').nth(1),
+      model: root.getByRole('combobox').nth(2),
+      tasksPerPage: root.getByRole('combobox').nth(3),
+    },
+    buttons: {
+      close: root.getByRole('button', { name: 'Close' }),
+      save: root.getByRole('button', {
+        name: /^(Save|Speichern|Guardar|Enregistrer)$/,
+      }),
+      trash: root.getByRole('button', { name: '🗑️' }),
+    },
+    spinners: {
+      deletingTasks: root.getByTestId("stSpinner"),
+    }
+  };
+  const interactions = {
+    chooseLanguage: async (language: string) => {
+      await locators.comboboxes.language.fill(language);
+      await locators.comboboxes.language.press('Enter');
+    },
+    chooseTimezone: async (timezone: string) => {
+      await locators.comboboxes.timezone.fill(timezone);
+      await locators.comboboxes.timezone.press('Enter');
+    },
+    chooseModel: async (model: string) => {
+      await locators.comboboxes.model.fill(model);
+      await locators.comboboxes.model.press('Enter');
+    },
+    chooseTasksPerPage: async (tasksPerPage: string) => {
+      await locators.comboboxes.tasksPerPage.fill(tasksPerPage);
+      await locators.comboboxes.tasksPerPage.press('Enter');
+    },
+    close: async () => {
+      await locators.buttons.close.click();
+    },
+    save: async () => {
+      await locators.buttons.save.click();
+    },
+    trashTasks: async () => {
+      await locators.buttons.trash.click();
+      await expect(locators.spinners.deletingTasks).toBeVisible();
+      await expect(locators.spinners.deletingTasks).toBeHidden();
+    },
+    uncheckFavorites: async () => {
+      await locators.checkboxes.keepFavorites.uncheck();
+    },
+  };
+  return Object.assign(() => root, { locators, do: interactions });
+}


Reviews:
user: login: besessener
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T07:40:06Z
user: login: besessener
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T07:45:18Z
user: login: besessener
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T07:47:11Z
user: login: csvtuda
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T09:35:11Z
user: login: csvtuda
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T09:36:06Z
user: login: besessener
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-23T11:05:59Z

Review comments:
diff_hunk: @@ -0,0 +1,95 @@
+import { Locator, Page } from '@playwright/test';
+
+interface SettingsModal {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
path: tests/e2e/tests/settings-modal.ts
user: login: besessener
body: interesting
created_at: 2025-05-23T07:40:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+import { Locator, Page } from '@playwright/test';
+
+interface InfoModal {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
+    close(): Promise<void>;
+  };
+  /**
+   * Raw locators.
+   */
+  locators: {
+    buttons: {
+      close: Locator;
+    };
+  };
+}
+
+export function initInfoModal(page: Page): InfoModal {
+  const root = page.getByRole('dialog');
+  const locators = {
+    buttons: {
+      close: page
+        .getByRole('dialog')
+        .getByRole('button', { name: 'Close' })
+        .nth(1),
+    },
+  };
+  const interactions = {
+    close: async () => {
+      await locators.buttons.close.click();
+    },
+  };
+  return Object.assign(() => root, { locators, do: interactions });
path: tests/e2e/tests/info-modal.ts
user: login: besessener
body: crazy typescript. but i like the result.
created_at: 2025-05-23T07:45:18Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,34 @@
+import test, { Page } from '@playwright/test';
+import { initInfoModal } from './info-modal';
+import { initProcrastinationPage } from './procrastination-page';
+import { initSettingsModal } from './settings-modal';
+
+function createPageTree(page: Page) {
+  // We use getters here for lazy evaluation. There's no need to create all page objects if we're
+  // just interested in a single one.
+  return {
+    get main() {
+      return initProcrastinationPage(page);
+    },
+    get modal() {
+      return {
+        get info() {
+          return initInfoModal(page);
+        },
+        get settings() {
+          return initSettingsModal(page);
+        },
+      };
+    },
+  };
+}
+
+export const buddyTest = test.extend<{
+  on: typeof createPageTree;
path: tests/e2e/tests/procrastination-fixture.ts
user: login: besessener
body: haha, now I see the beauty. accessing pageobject from page itself look nice
created_at: 2025-05-23T07:47:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,34 @@
+import test, { Page } from '@playwright/test';
+import { initInfoModal } from './info-modal';
+import { initProcrastinationPage } from './procrastination-page';
+import { initSettingsModal } from './settings-modal';
+
+function createPageTree(page: Page) {
+  // We use getters here for lazy evaluation. There's no need to create all page objects if we're
+  // just interested in a single one.
+  return {
+    get main() {
+      return initProcrastinationPage(page);
+    },
+    get modal() {
+      return {
+        get info() {
+          return initInfoModal(page);
+        },
+        get settings() {
+          return initSettingsModal(page);
+        },
+      };
+    },
+  };
+}
+
+export const buddyTest = test.extend<{
+  on: typeof createPageTree;
path: tests/e2e/tests/procrastination-fixture.ts
user: login: csvtuda
body: You can also use actual classes. They might be more performant with their prototype function members in constrast to creating new functions for every page object access (each time `on(...).mypage...` is invoked, the functions/locators inside `do` and so on are created again.)

But then again it would be a premature optimisation.
created_at: 2025-05-23T09:35:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+import { Locator, Page } from '@playwright/test';
+
+interface InfoModal {
+  /**
+   * Points to self.
+   */
+  (): Locator;
+  /**
+   * High-level interactions.
+   */
+  do: {
+    close(): Promise<void>;
+  };
+  /**
+   * Raw locators.
+   */
+  locators: {
+    buttons: {
+      close: Locator;
+    };
+  };
+}
+
+export function initInfoModal(page: Page): InfoModal {
+  const root = page.getByRole('dialog');
+  const locators = {
+    buttons: {
+      close: page
+        .getByRole('dialog')
+        .getByRole('button', { name: 'Close' })
+        .nth(1),
+    },
+  };
+  const interactions = {
+    close: async () => {
+      await locators.buttons.close.click();
+    },
+  };
+  return Object.assign(() => root, { locators, do: interactions });
path: tests/e2e/tests/info-modal.ts
user: login: csvtuda
body: Just JavaScript, actually 😆 Assigning additional properties to functions
created_at: 2025-05-23T09:36:06Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T13:11:50Z
body: ## Backend Unit Tests
15 tests  ±0   15 ✅ ±0   2s ⏱️ ±0s
 1 suites ±0    0 💤 ±0 
 1 files   ±0    0 ❌ ±0 

Results for commit 22a14287. ± Comparison against base commit 870fe4b8.

[test-results]:data:application/gzip;base64,H4sIACxKMGgC/12Myw7CIBBFf6Vh7YJOq4A/Y2AcEmJbDI9V47+LqNi6u+fc5KzMuokiO3f9oWMxu9TgmoNOzi8FoWA5Ur2OX7jEjPhnbu5eDG/CajftBIXgw8eEvLTia++Cb/HrVd7kKm9r6OfZpQIMQPcjSCEJB0uSJAcgA1wbRCUGJcmIUakTezwB/C9CS/8AAAA=

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-22T13:12:01Z
body: ## Frontend Unit Tests
17 tests  ±0   17 ✅ ±0   0s ⏱️ ±0s
 1 suites ±0    0 💤 ±0 
 1 files   ±0    0 ❌ ±0 

Results for commit 22a14287. ± Comparison against base commit 870fe4b8.

[test-results]:data:application/gzip;base64,H4sIADpKMGgC/12MzQ7CIBAGX6Xh7IFuGwFfpoF1SYhtMfycGt9dRK3U2858m9mYdTNFdun6U8didmmHaw46Ob8W5AXLkOokvjDFjPhnbu7e/k9Wu/kgKAQfPibkdS++7kPwLX69yk2ucltDvywuFWAAuh9BCkk4WJIkOQAZ4NogKjEoSUaMSp3Z4wnUvVlX/wAAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-22T13:17:26Z
body: ## Playwright UI Tests
10 tests  ±0   10 ✅ ±0   2m 47s ⏱️ -22s
 1 suites ±0    0 💤 ±0 
 1 files   ±0    0 ❌ ±0 

Results for commit 22a14287. ± Comparison against base commit 870fe4b8.

[test-results]:data:application/gzip;base64,H4sIAFBLMGgC/12Myw7CIBBFf6Vh7YJOmwL+jIFxSIhtMTxWjf8uRe3D3T3nJmdh1o0U2bVpLw2L2aUN7jno5Py84iCKKFeqJ//BLWbEP/Nwz2J2YbUbT4JC8OFrQp634rpPwY/Ye5UPucrHGvppcqkAA9BtD1JIws6SJMkByADXBlGJTkkyoldqYK83yd8RqgEBAAA=

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: besessener
created_at: 2025-05-22T19:54:16Z
body: I added the delay click function, because I had some issues when elements were clicked too fast. Unfortunately only a sporadic issue. Especially noticed when changing the like toggle.
Did you not notice anything like this? 
Thought maybe it was a streamlit issue. If it's gone then maybe my implementation changes have helped....
author_association: COLLABORATOR
user: login: besessener
created_at: 2025-05-22T19:55:54Z
body: (did not check code changes... Only commenting from my phone XD)
author_association: COLLABORATOR
user: login: csvtuda
created_at: 2025-05-22T20:03:07Z
body: > Unfortunately only a sporadic issue. Especially noticed when changing the like toggle. Did you not notice anything like this?

I think the only time I ran into flaky tests was due to non-retrying code:

```ts
const taskText1 = await tasks.nth(0).textContent();

await clickWithDelay(locators.settingsButton);
await fillWithDelay(page.getByRole('combobox').nth(1), 'America/New_York');
await clickWithDelay(locators.saveButton);

const taskText2 = await tasks.nth(0).textContent(); // here
expect(taskText1).not.toEqual(taskText2);           // here
```

This is a better assertion:

```ts
await expect(tasks.nth(0)).not.toHaveText(taskText1);
```

---

My updated spec did not run into any flaky tests on my machine 🤔 Currently I'd say that it should be pretty stable.
author_association: COLLABORATOR
user: login: besessener
created_at: 2025-05-23T07:55:24Z
body: its just a pity the bruno tests don't run anymore due to your previous change i suppose. unfortunately the workflow did not detect it as an error
author_association: COLLABORATOR
user: login: csvtuda
created_at: 2025-05-23T09:26:26Z
body: > its just a pity the bruno tests don't run anymore due to your previous change i suppose

Oops, I did not look at them at all. Yeah, I guess it makes sense to still keep the backend exposed as well.
author_association: COLLABORATOR
user: login: besessener
created_at: 2025-05-23T09:31:47Z
body: was running the e2e tests with repeatEach: 30, to detect flakiness. it's only 3 out of 30 that failed, but 3 times the same:

![image](https://github.com/user-attachments/assets/22eeead1-a33e-4ac7-9dbd-361f28487664)

But I am not sure if its the same i had before.
author_association: COLLABORATOR
user: login: besessener
created_at: 2025-05-23T09:37:57Z
body: And frankly I have no idea if it was better or worse before. First time I tried the repeatEach
author_association: COLLABORATOR
user: login: csvtuda
created_at: 2025-05-23T10:14:26Z
body: I ran the suites with repeatEach 10 and did not encounter any failing tests. Works on my machine 🤓 

But anyways, I added two assertions when deleting tasks in 22a1428. Maybe this will help slow things down?
author_association: COLLABORATOR
user: login: besessener
created_at: 2025-05-23T11:05:44Z
body: also no issue on my side anymore now. but i don't think this was the right thing. what I did not show you was that there was still a task visible. thats why it did not see the "no tasks " element. but anyway. it was so rare that i would rather keep the speed up by removing 500ms waits. i wont even use a retry value in CI. if this happens more often I will consider to do the clean up over API. both settings and DB deletion.

thank you!
author_association: COLLABORATOR