# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: settings/courses.spec.ts >> Settings: Courses >> create and delete a course
- Location: src/tests/settings/courses.spec.ts:148:7

# Error details

```
Test timeout of 120000ms exceeded.
```

```
Error: locator.click: Target page, context or browser has been closed
Call log:
  - waiting for locator('.p-dialog-mask .p-dialog').first().locator('p-inputnumber input').first()
    - locator resolved to <input pc35="" pc36="" max="99" data-p="" min="0.01" step="0.1" pinputtext="" autofocus="true" role="spinbutton" aria-valuemax="99" inputmode="decimal" aria-valuemin="0.01" data-pc-section="root" data-pc-name="pcinputtext" data-pc-extend="inputtext" class="p-inputnumber-input p-component p-inputtext"/>
  - attempting click action
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <li pc55="" pripple="" role="option" id="pn_id_14_1" aria-setsize="3" aria-posinset="2" aria-label="Niger" aria-selected="false" aria-disabled="false" data-p-focused="false" data-p-selected="false" data-pc-section="option" class="p-ripple p-autocomplete-option">…</li> from <div pc34="" pc28="" data-pc-section="root" data-pc-name="pcoverlay" data-pc-extend="overlay" class="p-component p-overlay">…</div> subtree intercepts pointer events
    - retrying click action
    - waiting 20ms
    2 × waiting for element to be visible, enabled and stable
      - element is visible, enabled and stable
      - scrolling into view if needed
      - done scrolling
      - <li pc55="" pripple="" role="option" id="pn_id_14_1" aria-setsize="3" aria-posinset="2" aria-label="Niger" aria-selected="false" aria-disabled="false" data-p-focused="false" data-p-selected="false" data-pc-section="option" class="p-ripple p-autocomplete-option">…</li> from <div pc34="" pc28="" data-pc-section="root" data-pc-name="pcoverlay" data-pc-extend="overlay" class="p-component p-overlay">…</div> subtree intercepts pointer events
    - retrying click action
      - waiting 100ms
    209 × waiting for element to be visible, enabled and stable
        - element is visible, enabled and stable
        - scrolling into view if needed
        - done scrolling
        - <li pc55="" pripple="" role="option" id="pn_id_14_1" aria-setsize="3" aria-posinset="2" aria-label="Niger" aria-selected="false" aria-disabled="false" data-p-focused="false" data-p-selected="false" data-pc-section="option" class="p-ripple p-autocomplete-option">…</li> from <div pc34="" pc28="" data-pc-section="root" data-pc-name="pcoverlay" data-pc-extend="overlay" class="p-component p-overlay">…</div> subtree intercepts pointer events
      - retrying click action
        - waiting 500ms

```

# Page snapshot

```yaml
- generic [ref=e1]:
  - generic [ref=e2]:
    - generic [ref=e3]:
      - generic [ref=e5]:
        - generic [ref=e8]:
          - link "MasterEV Home" [ref=e9] [cursor=pointer]:
            - /url: /admin
            - img [ref=e12]
          - link "MasterEV" [ref=e18] [cursor=pointer]:
            - /url: /admin
        - menubar
        - generic [ref=e24] [cursor=pointer]:
          - generic [ref=e25]: 
          - generic [ref=e28]: E2E ADMIN settings-courses-spec-ts
          - generic [ref=e29]: 
      - generic [ref=e32]:
        - generic [ref=e36]:
          - heading "Einstellungen" [level=1] [ref=e37]
          - paragraph [ref=e38]: Benutzer:innen- und Systemeinstellungen
        - generic [ref=e42]:
          - tablist [ref=e45]:
            - tab "Benutzer:innen" [ref=e47] [cursor=pointer]
            - tab "Teams" [ref=e49] [cursor=pointer]
            - tab "Bewerber-Accounts" [ref=e51] [cursor=pointer]
            - tab "Universitäten" [selected] [ref=e53] [cursor=pointer]
            - tab "Module" [ref=e55] [cursor=pointer]
            - tab "Tags" [ref=e57] [cursor=pointer]
            - tab "Vorlagen" [ref=e59] [cursor=pointer]
          - generic [ref=e62]:
            - generic [ref=e63]:
              - generic [ref=e64]:
                - heading "Universitäten" [level=2] [ref=e65]
                - paragraph [ref=e66]: Verwalten Sie die Universitäten und deren Kurse
              - generic [ref=e67]:
                - generic [ref=e70]:
                  - generic [ref=e71]: 
                  - textbox "Suchen" [ref=e72]
                - button " Erstellen" [ref=e74] [cursor=pointer]:
                  - generic [ref=e75]: 
                  - generic [ref=e76]: Erstellen
            - generic [ref=e77]:
              - generic [ref=e79]:
                - generic [ref=e81] [cursor=pointer]:
                  - text: Name
                  - generic [ref=e82]: 
                - generic [ref=e84] [cursor=pointer]:
                  - text: Land
                  - generic [ref=e85]: 
                - generic [ref=e87] [cursor=pointer]:
                  - text: Faktor
                  - generic [ref=e88]: 
              - generic [ref=e92]:
                - generic [ref=e93] [cursor=pointer]:
                  - generic [ref=e95]: E2E CourseUni mrw2hr2bd50b
                  - generic [ref=e97]: Algerien
                  - generic [ref=e99]: "1.00"
                  - button "" [ref=e102]:
                    - generic [ref=e103]: 
                - generic [ref=e104] [cursor=pointer]:
                  - generic [ref=e106]: E2E CourseUni mrw2hwts36bn
                  - generic [ref=e108]: Algerien
                  - generic [ref=e110]: "1.00"
                  - button "" [ref=e113]:
                    - generic [ref=e114]: 
                - generic [ref=e115] [cursor=pointer]:
                  - generic [ref=e117]: E2E CourseUni mrw2i2sep4ub
                  - generic [ref=e119]: Algerien
                  - generic [ref=e121]: "1.00"
                  - button "" [ref=e124]:
                    - generic [ref=e125]: 
                - generic [ref=e126] [cursor=pointer]:
                  - generic [ref=e128]: E2E CourseUni mrw2i6pqhej1
                  - generic [ref=e130]: Algerien
                  - generic [ref=e132]: "1.00"
                  - button "" [ref=e135]:
                    - generic [ref=e136]: 
                - generic [ref=e137] [cursor=pointer]:
                  - generic [ref=e139]: E2E Row mrw2k6iygt2a
                  - generic [ref=e141]: DZ
                  - generic [ref=e143]: "1.00"
                  - button "" [ref=e146]:
                    - generic [ref=e147]: 
                - generic [ref=e148] [cursor=pointer]:
                  - generic [ref=e150]: E2E University mrw2k38o1c3s
                  - generic [ref=e152]: Algerien
                  - generic [ref=e154]: "1.00"
                  - button "" [ref=e157]:
                    - generic [ref=e158]: 
      - generic [ref=e162]:
        - link " Impressum" [ref=e165] [cursor=pointer]:
          - /url: /pages/sitenotice
          - generic [ref=e166]: 
          - generic [ref=e167]: Impressum
        - link " E2E Reemit mrvz3ke3gk6h" [ref=e170] [cursor=pointer]:
          - /url: /pages/privacy
          - generic [ref=e171]: 
          - generic [ref=e172]: E2E Reemit mrvz3ke3gk6h
        - generic [ref=e175] [cursor=pointer]:
          - generic [ref=e176]: 
          - generic [ref=e177]: Kontakt
        - generic [ref=e181] [cursor=pointer]: 
    - generic:
      - generic:
        - alertdialog
  - dialog "Universität anlegen" [ref=e183]:
    - generic [ref=e186]:
      - generic [ref=e187]: Universität anlegen
      - button "Close" [ref=e190] [cursor=pointer]:
        - img [ref=e191]
    - generic [ref=e194]:
      - generic [ref=e196]:
        - generic [ref=e197]:
          - generic [ref=e198]: Name
          - textbox [ref=e199]: E2E CourseUni mrw2ht2xafqx
        - generic [ref=e200]:
          - generic [ref=e201]: Land
          - generic [ref=e202]:
            - combobox [expanded] [active] [ref=e203]: Algerien
            - button [ref=e204] [cursor=pointer]:
              - img [ref=e205]
        - generic [ref=e207]:
          - generic [ref=e208]: Faktor
          - generic [ref=e209]:
            - spinbutton [ref=e210]
            - generic [ref=e211]:
              - button [ref=e212] [cursor=pointer]:
                - img [ref=e213]
              - button [ref=e215] [cursor=pointer]:
                - img [ref=e216]
        - generic [ref=e218]:
          - generic [ref=e219]: Kommentar
          - textbox [ref=e220]
        - generic [ref=e221]:
          - generic [ref=e222]: Aktiviert
          - switch [checked] [ref=e224] [cursor=pointer]
      - generic [ref=e228]:
        - button "Abbrechen" [ref=e230] [cursor=pointer]:
          - generic [ref=e231]: Abbrechen
        - button "Speichern" [disabled] [ref=e233]:
          - generic [ref=e234]: Speichern
  - generic [ref=e238]:
    - listbox "Option List" [ref=e241]:
      - option "Algerien" [selected] [ref=e242] [cursor=pointer]: Algerien (DZ)
      - option "Niger" [ref=e243] [cursor=pointer]: Niger (NE)
      - option "Nigeria" [ref=e244] [cursor=pointer]: Nigeria (NG)
    - status [ref=e245]: 1 items selected
```

# Test source

```ts
  40  | ): Locator {
  41  |   return page
  42  |     .locator(opts.rowSelector ?? DEFAULT_ROW_SELECTOR)
  43  |     .filter({ hasText: text });
  44  | }
  45  | 
  46  | async function fillSearchUntilRowVisible(
  47  |   page: Page,
  48  |   text: string,
  49  |   rows: Locator,
  50  |   timeout: number
  51  | ): Promise<void> {
  52  |   const searchInput = page.locator('masterev-search-input input').first();
  53  |   await expect(searchInput).toBeVisible({ timeout: 10_000 });
  54  |   // Re-fill the search box on each poll: a just-saved row that arrives
  55  |   // after the first fill still gets picked up under staging load, where
  56  |   // Convex reactivity + the search index can lag the mutation.
  57  |   await expect(async () => {
  58  |     await searchInput.fill('');
  59  |     await searchInput.fill(text);
  60  |     await expect(rows.first()).toBeVisible({ timeout: 5_000 });
  61  |   }).toPass({ timeout });
  62  | }
  63  | 
  64  | /**
  65  |  * Search for `text` and wait until a matching row is visible, so a
  66  |  * downstream row-scoped `edit-button` click reliably hits the filtered
  67  |  * row (the bare `.first()` races the debounced filter and previously
  68  |  * edited whatever row topped the unfiltered list).
  69  |  *
  70  |  * Retries once via reload (Convex search-index lag on loaded staging),
  71  |  * then HARD-FAILS: a row that never appears means the create/update was
  72  |  * rejected — a bug, not a skip.
  73  |  */
  74  | export async function searchAndFind(
  75  |   page: Page,
  76  |   text: string,
  77  |   opts: SearchOptions = {}
  78  | ): Promise<Locator> {
  79  |   const rows = matchingRows(page, text, opts);
  80  |   try {
  81  |     await fillSearchUntilRowVisible(page, text, rows, UI_SETTLE_TIMEOUT);
  82  |   } catch {
  83  |     // One reload, then a second settle budget: reactivity occasionally
  84  |     // needs a fresh query subscription on loaded staging.
  85  |     await page.goto(page.url().split('?')[0] ?? page.url());
  86  |     await fillSearchUntilRowVisible(page, text, rows, UI_SETTLE_TIMEOUT);
  87  |   }
  88  |   return rows;
  89  | }
  90  | 
  91  | /**
  92  |  * After a delete: search for `text` and assert it never (re)appears.
  93  |  * Reloads first so the assertion runs against a fresh subscription.
  94  |  *
  95  |  * The absence check is anchored on an AFFIRMATIVE loaded state first:
  96  |  * right after navigation the table trivially has zero rows while the
  97  |  * query is still in flight, so a bare `toHaveCount(0)` would pass
  98  |  * vacuously and green-light a delete the server silently rejected.
  99  |  * Both table variants render `[data-testid="table-empty-state"]` only
  100 |  * once loading finished; alternatively some OTHER row may render (the
  101 |  * search can partially match unrelated rows).
  102 |  */
  103 | export async function expectGoneFromSearch(
  104 |   page: Page,
  105 |   text: string,
  106 |   opts: SearchOptions = {}
  107 | ): Promise<void> {
  108 |   await page.goto(page.url().split('?')[0] ?? page.url());
  109 |   const searchInput = page.locator('masterev-search-input input').first();
  110 |   await expect(searchInput).toBeVisible({ timeout: 10_000 });
  111 |   await searchInput.fill(text);
  112 | 
  113 |   // Wait for the table to be LOADED: either the empty state rendered or
  114 |   // some data row did. Only then is a zero match count meaningful.
  115 |   const anyRow = page.locator(opts.rowSelector ?? DEFAULT_ROW_SELECTOR).first();
  116 |   const emptyState = page.locator('[data-testid="table-empty-state"]').first();
  117 |   await expect(
  118 |     anyRow.or(emptyState).first(),
  119 |     'table must finish loading (empty state or rows) before the absence check'
  120 |   ).toBeVisible({ timeout: UI_SETTLE_TIMEOUT });
  121 | 
  122 |   const rows = matchingRows(page, text, opts);
  123 |   await expect(rows, `"${text}" must disappear after delete`).toHaveCount(0, {
  124 |     timeout: UI_SETTLE_TIMEOUT,
  125 |   });
  126 | }
  127 | 
  128 | /**
  129 |  * Commit a value into a PrimeNG InputNumber. A plain `fill()`
  130 |  * intermittently leaves the form control untouched (PrimeNG parses
  131 |  * keystrokes via its own input handler), which keeps required fields
  132 |  * invalid and the save button disabled. Click, clear, type the digits,
  133 |  * then blur via the dialog's first input to run change detection.
  134 |  */
  135 | export async function fillNumberInput(
  136 |   dialog: Locator,
  137 |   input: Locator,
  138 |   value: string
  139 | ): Promise<void> {
> 140 |   await input.click();
      |               ^ Error: locator.click: Target page, context or browser has been closed
  141 |   await input.fill('');
  142 |   await input.pressSequentially(value);
  143 |   await dialog.locator('input').first().click();
  144 | }
  145 | 
  146 | /** Open the page's create dialog and return its locator. */
  147 | export async function openCreateDialog(page: Page): Promise<Locator> {
  148 |   const createButton = page.locator('[data-testid="create-button"]').first();
  149 |   await expect(createButton).toBeVisible({ timeout: 10_000 });
  150 |   await createButton.click();
  151 |   const dialog = page.locator(DIALOG).first();
  152 |   await expect(dialog).toBeVisible({ timeout: 5_000 });
  153 |   return dialog;
  154 | }
  155 | 
  156 | /**
  157 |  * Resolve a `data-testid` action button to its NATIVE <button>. The
  158 |  * testid sits either directly on a native button or on a PrimeNG
  159 |  * <p-button> host — and the host never carries the disabled state, so
  160 |  * enabled/disabled checks against it silently pass and a click can land
  161 |  * on a disabled inner button as a no-op.
  162 |  */
  163 | function nativeActionButton(dialog: Locator, testId: string): Locator {
  164 |   return dialog
  165 |     .locator(`button[data-testid="${testId}"], [data-testid="${testId}"] button`)
  166 |     .first();
  167 | }
  168 | 
  169 | /**
  170 |  * Click save and require the dialog to close. A dialog that stays open
  171 |  * means the server rejected the mutation (or a validation error is
  172 |  * showing) — that is a product bug and MUST fail the test, never be
  173 |  * dismissed with Escape and skipped.
  174 |  */
  175 | export async function saveDialog(dialog: Locator): Promise<void> {
  176 |   const saveButton = nativeActionButton(dialog, 'save-button');
  177 |   // Full settle budget: async validators + Convex round-trips keep the
  178 |   // button disabled noticeably longer when parallel workers load the
  179 |   // backend. A form that is genuinely invalid still fails here — with a
  180 |   // message that says so — just after the generous budget.
  181 |   await expect(
  182 |     saveButton,
  183 |     'save button must be enabled (a disabled save button means the form is invalid)'
  184 |   ).toBeEnabled({ timeout: UI_SETTLE_TIMEOUT });
  185 |   await saveButton.click();
  186 |   await expect(
  187 |     dialog,
  188 |     'save dialog must close — a dialog that stays open means the mutation was rejected'
  189 |   ).not.toBeVisible({ timeout: UI_SETTLE_TIMEOUT });
  190 | }
  191 | 
  192 | /**
  193 |  * Search for `text` and open its row's edit dialog. When `hydratedValue`
  194 |  * is given (default: `text`), waits for the dialog's first input to show
  195 |  * it — confirming the edit form opened the RIGHT entity and finished
  196 |  * hydrating before the caller interacts with other fields.
  197 |  */
  198 | export async function openRowEditor(
  199 |   page: Page,
  200 |   text: string,
  201 |   opts: SearchOptions & { hydratedValue?: string | null } = {}
  202 | ): Promise<Locator> {
  203 |   const rows = await searchAndFind(page, text, opts);
  204 |   await rows.first().locator('[data-testid="edit-button"]').first().click();
  205 |   const dialog = page.locator(DIALOG).first();
  206 |   await expect(dialog).toBeVisible({ timeout: 5_000 });
  207 |   const hydratedValue = opts.hydratedValue === undefined ? text : opts.hydratedValue;
  208 |   if (hydratedValue !== null) {
  209 |     await expect(dialog.locator('input').first()).toHaveValue(hydratedValue, {
  210 |       timeout: 5_000,
  211 |     });
  212 |   }
  213 |   return dialog;
  214 | }
  215 | 
  216 | /**
  217 |  * Click the edit dialog's delete button, accept the PrimeNG confirm
  218 |  * dialog and require the edit dialog to close. A confirm flow that does
  219 |  * not close the dialog means the deletion was rejected — fail, don't skip.
  220 |  */
  221 | export async function deleteViaDialog(page: Page, dialog: Locator): Promise<void> {
  222 |   const deleteButton = nativeActionButton(dialog, 'delete-button');
  223 |   await expect(deleteButton).toBeVisible({ timeout: 5_000 });
  224 |   await expect(deleteButton).toBeEnabled({ timeout: 5_000 });
  225 |   await deleteButton.click();
  226 | 
  227 |   const confirmDialog = page.locator('[role="alertdialog"]:visible').first();
  228 |   await expect(confirmDialog).toBeVisible({ timeout: 5_000 });
  229 |   await confirmDialog
  230 |     .locator('.p-confirmdialog-accept-button, .p-confirm-dialog-accept')
  231 |     .first()
  232 |     .click();
  233 | 
  234 |   await expect(
  235 |     dialog,
  236 |     'edit dialog must close after confirmed delete — staying open means the deletion was rejected'
  237 |   ).not.toBeVisible({ timeout: UI_SETTLE_TIMEOUT });
  238 | }
  239 | 
  240 | // ---------------------------------------------------------------------------
```