# 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: applications/bwl/suite.spec.ts >> Permissions for bwl >> USER in write team sees only their seeded applications
- Location: src/framework/specs/permissions.spec.ts:94:9

# Error details

```
Error: expect(locator).toBeVisible() failed

Locator: locator('[data-testid="application-row-pd744r7ybpq9t1zbgg74ybxyc186r9fw"]')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeVisible" with timeout 5000ms
  - waiting for locator('[data-testid="application-row-pd744r7ybpq9t1zbgg74ybxyc186r9fw"]')

```

# Page snapshot

```yaml
- generic [ref=e2]:
  - generic [ref=e3]:
    - generic [ref=e5]:
      - generic [ref=e8]:
        - link "MasterEV Home" [ref=e9] [cursor=pointer]:
          - /url: /admin/applications/bwl
          - img [ref=e12]
        - generic [ref=e17]:
          - link "BMT München" [ref=e18] [cursor=pointer]:
            - /url: /admin/applications/bwl
          - button "E2EFAKES" [disabled] [ref=e20]:
            - generic [ref=e24]:
              - generic [ref=e25]: 
              - generic [ref=e26]: E2EFAKES
              - generic [ref=e27]: 
      - menubar [ref=e28]:
        - menuitem "Bewerbungen" [ref=e29]:
          - link "Bewerbungen" [ref=e31] [cursor=pointer]:
            - /url: /admin/applications/bwl/list
            - generic [ref=e32]: Bewerbungen
            - img [ref=e33]
      - generic [ref=e36]:
        - combobox "Suche in BMT München" [active] [ref=e38]
        - generic [ref=e42] [cursor=pointer]:
          - generic [ref=e43]: 
          - generic [ref=e46]:
            - text: E2E USER applications-bwl-suite-spec-ts
            - generic [ref=e49]: twrite
          - generic [ref=e50]: 
    - generic [ref=e54]:
      - generic [ref=e58]:
        - heading "Keine Bewerbungen" [level=1] [ref=e59]
        - paragraph [ref=e60]: Alle Bewerbungen
      - generic [ref=e66]:
        - generic [ref=e68]:
          - generic [ref=e71]:
            - generic [ref=e72]: 
            - textbox "Suchen (Name, Application-ID, E-Mail)" [ref=e73]
          - button " Exportieren" [ref=e75] [cursor=pointer]:
            - generic [ref=e76]: 
            - generic [ref=e77]: Exportieren
        - generic [ref=e78]:
          - generic [ref=e80]:
            - generic [ref=e82] [cursor=pointer]:
              - text: Name
              - generic [ref=e83]: 
            - generic [ref=e85] [cursor=pointer]:
              - text: Antrags-Nr.
              - generic [ref=e86]: 
            - generic [ref=e88] [cursor=pointer]:
              - text: Bewerber-Nr.
              - generic [ref=e89]: 
            - generic [ref=e91] [cursor=pointer]:
              - text: Abgesendet
              - generic [ref=e92]: 
            - generic [ref=e94]: Tags
          - generic [ref=e99]: Keine Ergebnisse gefunden
    - generic [ref=e102]:
      - link " Impressum" [ref=e105] [cursor=pointer]:
        - /url: /pages/sitenotice
        - generic [ref=e106]: 
        - generic [ref=e107]: Impressum
      - link " E2E Test Title mp6wmt07hpuf" [ref=e110] [cursor=pointer]:
        - /url: /pages/privacy
        - generic [ref=e111]: 
        - generic [ref=e112]: E2E Test Title mp6wmt07hpuf
      - generic [ref=e116] [cursor=pointer]: 
  - generic:
    - generic:
      - alertdialog
```

# Test source

```ts
  9   |  *      application, can open detail.
  10  |  *   4. USER in team with step-read-only access sees the application
  11  |  *      in the list and detail, but the field-write attempt is denied.
  12  |  *   5. USER without matching team sees empty list, detail navigates
  13  |  *      to "Bewerbung nicht gefunden" (no crash, no leak).
  14  |  *
  15  |  * Each process exports a `ProcessActions` value with a
  16  |  * `capabilities.permissions` block; this spec consumes it.
  17  |  */
  18  | 
  19  | import { expect } from '@playwright/test';
  20  | import { test } from '../../fixtures.js';
  21  | import { convexRun } from '../../helpers/convex.js';
  22  | import * as apps from '../../helpers/applications.js';
  23  | import type {
  24  |   ProcessActions,
  25  |   ProcessPermissionsCapability,
  26  | } from '../process-actions.js';
  27  | 
  28  | interface RegisterOptions {
  29  |   /** Process actions exposing `capabilities.permissions`. */
  30  |   actions: ProcessActions;
  31  |   /**
  32  |    * Optional skip-with-reason. Mirror of the existing
  33  |    * `skipUnlessXxxOnConvex` pattern. Called per test, can use
  34  |    * `testInfo` if needed. Return non-empty string to skip.
  35  |    */
  36  |   skipReason?: (ctx: { customerName: string }) => string | undefined;
  37  | }
  38  | 
  39  | /**
  40  |  * Register the permissions spec for a given process. Call from a
  41  |  * thin wrapper file under `apps/e2e/src/tests/applications/<proc>/`.
  42  |  */
  43  | export function registerPermissionsSpec(opts: RegisterOptions): void {
  44  |   const { actions } = opts;
  45  |   const cap = actions.capabilities?.permissions;
  46  | 
  47  |   test.describe(`Permissions for ${actions.process}`, () => {
  48  |     test.beforeEach(({ customerConfig }, testInfo) => {
  49  |       // Suite-builder registers the spec once per process and Playwright
  50  |       // executes it across every customer project; without this guard
  51  |       // the bwl permissions tests run against tum-ed-as / tum-ed-cee /
  52  |       // tum-ed-me etc., where seeding fails because `bwl` is not a
  53  |       // process on those customers. Match the customer-skip pattern
  54  |       // already used by `dashboard.spec.ts` and `list-search.spec.ts`.
  55  |       test.skip(
  56  |         customerConfig.name !== actions.customer,
  57  |         `Suite for ${actions.process} only runs against ${actions.customer} (got ${customerConfig.name})`
  58  |       );
  59  |       const reason = opts.skipReason?.({
  60  |         customerName:
  61  |           (testInfo.project.metadata?.customer as string | undefined) ?? '',
  62  |       });
  63  |       if (reason) test.skip(true, reason);
  64  |       if (!cap) {
  65  |         test.skip(true, `${actions.process} has no permissions capability hint`);
  66  |       }
  67  |     });
  68  | 
  69  |     test('ADMIN sees the seeded application in the list', async ({
  70  |       adminPage,
  71  |     }) => {
  72  |       const seeded = await seedFor(actions, cap, 'write');
  73  |       try {
  74  |         await apps.gotoApplicationList(adminPage, actions.process);
  75  |         await expect(rowFor(adminPage, seeded._id)).toBeVisible();
  76  |       } finally {
  77  |         await apps.deleteApplication(seeded._id);
  78  |       }
  79  |     });
  80  | 
  81  |     test('POWERUSER without team membership sees the list (synthetic role:POWERUSER)', async ({
  82  |       createAuthenticatedUser,
  83  |     }) => {
  84  |       const seeded = await seedFor(actions, cap, 'write');
  85  |       try {
  86  |         const { page } = await createAuthenticatedUser({ role: 'POWERUSER' });
  87  |         await apps.gotoApplicationList(page, actions.process);
  88  |         await expect(rowFor(page, seeded._id)).toBeVisible();
  89  |       } finally {
  90  |         await apps.deleteApplication(seeded._id);
  91  |       }
  92  |     });
  93  | 
  94  |     test('USER in write team sees only their seeded applications', async ({
  95  |       createAuthenticatedUser,
  96  |     }) => {
  97  |       const writeTeamId = await ensureTeam(
  98  |         actions.customer,
  99  |         cap!.writeTeamShort,
  100 |         'E2E Write Team'
  101 |       );
  102 |       const seeded = await seedFor(actions, cap, 'write', writeTeamId);
  103 |       try {
  104 |         const { page } = await createAuthenticatedUser({
  105 |           role: 'USER',
  106 |           teamId: writeTeamId,
  107 |         });
  108 |         await apps.gotoApplicationList(page, actions.process);
> 109 |         await expect(rowFor(page, seeded._id)).toBeVisible();
      |                                                ^ Error: expect(locator).toBeVisible() failed
  110 |       } finally {
  111 |         await apps.deleteApplication(seeded._id);
  112 |       }
  113 |     });
  114 | 
  115 |     test('USER without matching team sees empty list and "not found" on direct detail', async ({
  116 |       createAuthenticatedUser,
  117 |     }) => {
  118 |       // Different team from the seeded write-team grant: no overlap.
  119 |       const otherTeamId = await ensureTeam(
  120 |         actions.customer,
  121 |         'tnomatch',
  122 |         'E2E No-Match Team'
  123 |       );
  124 |       const writeTeamId = await ensureTeam(
  125 |         actions.customer,
  126 |         cap!.writeTeamShort,
  127 |         'E2E Write Team'
  128 |       );
  129 |       const seeded = await seedFor(actions, cap, 'write', writeTeamId);
  130 |       try {
  131 |         const { page } = await createAuthenticatedUser({
  132 |           role: 'USER',
  133 |           teamId: otherTeamId,
  134 |         });
  135 |         await apps.gotoApplicationList(page, actions.process);
  136 |         await expect(rowFor(page, seeded._id)).not.toBeVisible();
  137 | 
  138 |         // Direct-navigate to the detail URL. The application is
  139 |         // filtered out by the RLS read predicate, so the convex query
  140 |         // returns null; the UI handles `query.data() === null` as an
  141 |         // empty state (no application headline, no field inputs, no
  142 |         // hard 404). Spec D4: "Detail-View read ... Empty/Spinner-
  143 |         // State (bei deny — kein crash, kein hard 404)." We assert
  144 |         // the absence of detail-page chrome rather than a specific
  145 |         // error string, because the current UI does not surface a
  146 |         // "not found" message.
  147 |         await page.goto(
  148 |           `/admin/applications/${actions.process}/details/${seeded._id}`
  149 |         );
  150 |         // The detail page renders an `<masterev-application-detail>`
  151 |         // host element when data is present; an absent host (or no
  152 |         // application-id heading) is the deny signal.
  153 |         await expect(
  154 |           page.locator('[data-testid="application-detail-content"]')
  155 |         ).toHaveCount(0);
  156 |         await expect(
  157 |           page.getByText(seeded.applicationId, { exact: true })
  158 |         ).toHaveCount(0);
  159 |       } finally {
  160 |         await apps.deleteApplication(seeded._id);
  161 |       }
  162 |     });
  163 |   });
  164 | }
  165 | 
  166 | async function seedFor(
  167 |   actions: ProcessActions,
  168 |   cap: ProcessPermissionsCapability | undefined,
  169 |   access: 'read' | 'write',
  170 |   teamIdOverride?: string
  171 | ) {
  172 |   if (!cap) {
  173 |     throw new Error(
  174 |       `seedFor called without a permissions capability on ${actions.process}`
  175 |     );
  176 |   }
  177 |   const teamId =
  178 |     teamIdOverride ??
  179 |     (await ensureTeam(
  180 |       actions.customer,
  181 |       access === 'write' ? cap.writeTeamShort : cap.readTeamShort,
  182 |       access === 'write' ? 'E2E Write Team' : 'E2E Read Team'
  183 |     ));
  184 |   return cap.seedApplicationWithStepPermission({
  185 |     customer: actions.customer,
  186 |     teamId,
  187 |     access,
  188 |   });
  189 | }
  190 | 
  191 | async function ensureTeam(
  192 |   customer: string,
  193 |   short: string,
  194 |   name: string
  195 | ): Promise<string> {
  196 |   // `e2e:createTeam` is idempotent on (customer, short).
  197 |   return convexRun<string>('e2e:createTeam', { customer, name, short });
  198 | }
  199 | 
  200 | function rowFor(page: import('@playwright/test').Page, applicationDocId: string) {
  201 |   // The list table is the InfiniteDataTable component which renders
  202 |   // each row with `data-testid="application-row-<docId>"` (see
  203 |   // `application-list.component.ts:rowTestIdPrefix`). Other framework
  204 |   // specs locate rows the same way; matching here on the testid is
  205 |   // resilient to column re-ordering and the divs-as-cells render
  206 |   // shape that defeats `getByRole('cell', ...)`.
  207 |   return page.locator(
  208 |     `[data-testid="application-row-${applicationDocId}"]`
  209 |   );
```