# 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 >> Status Lifecycle for bwl >> stage-1 reject keeps status in_progress until stage-2 admin step is done
- Location: src/framework/specs/status-lifecycle.spec.ts:76:9

# Error details

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

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

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

```

# 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]
        - menuitem "Stage 1" [ref=e35]:
          - link "Stage 1" [ref=e37] [cursor=pointer]:
            - /url: /admin/applications/bwl/process/stage1
            - generic [ref=e38]: Stage 1
            - img [ref=e39]
        - menuitem "Stage 2" [ref=e41]:
          - link "Stage 2" [ref=e43] [cursor=pointer]:
            - /url: /admin/applications/bwl/process/stage2
            - generic [ref=e44]: Stage 2
            - img [ref=e45]
      - generic [ref=e48]:
        - combobox "Suche in BMT München" [ref=e50]
        - generic [ref=e54] [cursor=pointer]:
          - generic [ref=e55]: 
          - generic [ref=e58]: E2E ADMIN applications-bwl-suite-spec-ts
          - generic [ref=e59]: 
    - generic [ref=e63]:
      - generic [ref=e67]:
        - heading "Keine Bewerbungen" [level=1] [ref=e68]
        - paragraph [ref=e69]: Alle Bewerbungen
      - generic [ref=e75]:
        - generic [ref=e77]:
          - generic [ref=e80]:
            - generic [ref=e81] [cursor=pointer]: 
            - textbox "Suchen (Name, Application-ID, E-Mail)" [active] [ref=e82]: BWLZEABF02
          - button " Exportieren" [ref=e84] [cursor=pointer]:
            - generic [ref=e85]: 
            - generic [ref=e86]: Exportieren
        - generic [ref=e87]:
          - generic [ref=e89]:
            - generic [ref=e91] [cursor=pointer]:
              - text: Name
              - generic [ref=e92]: 
            - generic [ref=e94] [cursor=pointer]:
              - text: Antrags-Nr.
              - generic [ref=e95]: 
            - generic [ref=e97] [cursor=pointer]:
              - text: Bewerber-Nr.
              - generic [ref=e98]: 
            - generic [ref=e100] [cursor=pointer]:
              - text: Abgesendet
              - generic [ref=e101]: 
            - generic [ref=e103]: Tags
          - generic [ref=e109]: "Keine Ergebnisse gefunden für: BWLZEABF02"
    - generic [ref=e112]:
      - link " Impressum" [ref=e115] [cursor=pointer]:
        - /url: /pages/sitenotice
        - generic [ref=e116]: 
        - generic [ref=e117]: Impressum
      - link " E2E Test Title mp6zekaynvij" [ref=e120] [cursor=pointer]:
        - /url: /pages/privacy
        - generic [ref=e121]: 
        - generic [ref=e122]: E2E Test Title mp6zekaynvij
      - generic [ref=e126] [cursor=pointer]: 
  - generic:
    - generic:
      - alertdialog
```

# Test source

```ts
  34  |     test.beforeEach(({ customerConfig }) => {
  35  |       test.skip(
  36  |         customerConfig.name !== actions.customer,
  37  |         `Suite for ${actions.process} only runs against ${actions.customer} (got ${customerConfig.name})`
  38  |       );
  39  |     });
  40  | 
  41  |     test('manual accept → reject → accept toggles the application status', async ({
  42  |       adminPage,
  43  |     }) => {
  44  |       const application = await actions.seedApplication();
  45  |       try {
  46  |         await actions.driveToAcceptableState(adminPage, application);
  47  | 
  48  |         await apps.setApplicationStatus(
  49  |           adminPage,
  50  |           application._id,
  51  |           'rejected',
  52  |           'manual reject for E2E lifecycle'
  53  |         );
  54  |         await apps.expectApplicationStatus(
  55  |           adminPage,
  56  |           application._id,
  57  |           'rejected'
  58  |         );
  59  | 
  60  |         await apps.setApplicationStatus(
  61  |           adminPage,
  62  |           application._id,
  63  |           'accepted',
  64  |           'reverting to accept for E2E lifecycle'
  65  |         );
  66  |         await apps.expectApplicationStatus(
  67  |           adminPage,
  68  |           application._id,
  69  |           'accepted'
  70  |         );
  71  |       } finally {
  72  |         await apps.deleteApplication(application._id);
  73  |       }
  74  |     });
  75  | 
  76  |     test('stage-1 reject keeps status in_progress until stage-2 admin step is done', async ({
  77  |       adminPage,
  78  |     }) => {
  79  |       // Only processes that opt in via `statusLifecycleDeferredFinalization`
  80  |       // have a post-Stage-1 admin step. Processes without it complete the
  81  |       // status flip during Stage 1 (no Stage 2 work to defer to) and the
  82  |       // assertion below would fail by design.
  83  |       // ADR-0018 Update 2026-05-11.
  84  |       if (!actions.capabilities?.statusLifecycleDeferredFinalization) {
  85  |         test.skip(
  86  |           true,
  87  |           `process ${actions.process} has no deferred finalisation (no post-Stage-1 admin step); behaviour is identical to legacy lifecycle`
  88  |         );
  89  |       }
  90  | 
  91  |       const application = await actions.seedApplication();
  92  |       try {
  93  |         // Drive Stage 1 to a reject outcome. After this the underlying
  94  |         // step output carries `decision: 'reject'` but the engine
  95  |         // keeps the application `in_progress` because the post-
  96  |         // decision admin step (BMT `s2.export`) is still open.
  97  |         await actions.driveToRejectableState(adminPage, application);
  98  |         await apps.expectApplicationStatus(
  99  |           adminPage,
  100 |           application._id,
  101 |           'in_progress'
  102 |         );
  103 | 
  104 |         // openspec/denormalize-active-step-keys: after Stage 1 closes
  105 |         // with a rejection-decision, only the post-decision admin step
  106 |         // (BMT `s2.export`) remains active. The denormalised array
  107 |         // on the application doc must reflect exactly that — Stage 1
  108 |         // tokens are gone, the post-decision step token is present.
  109 |         const tokensAfterReject = await convexRun<string[] | null>(
  110 |           'e2e:getApplicationActiveStageStepKeys',
  111 |           { applicationId: application._id }
  112 |         );
  113 |         expect(tokensAfterReject).not.toBeNull();
  114 |         expect(tokensAfterReject).toContain('s2:export');
  115 |         expect(
  116 |           tokensAfterReject!.some((k) => k.startsWith('s1:'))
  117 |         ).toBe(false);
  118 | 
  119 |         // Sanity: the application must still appear in the stage-2
  120 |         // step view. Before the fix the implicit status post-filter
  121 |         // hid rejected apps from this list and the admin had no way
  122 |         // to find their pending TUMonline booking.
  123 |         await apps.gotoApplicationList(adminPage, actions.process);
  124 |         const searchInput = adminPage
  125 |           .locator(
  126 |             '[data-testid="application-list-search"], masterev-search-input input'
  127 |           )
  128 |           .first();
  129 |         await searchInput.fill(application.applicationId);
  130 |         await expect(
  131 |           adminPage.locator(
  132 |             `[data-testid="application-row-${application._id}"]`
  133 |           )
> 134 |         ).toBeVisible({ timeout: 10_000 });
      |           ^ Error: expect(locator).toBeVisible() failed
  135 | 
  136 |         // Detail view: while the application is still in_progress the
  137 |         // stage-1 result panel shows "Zwischenergebnis" (interim) and
  138 |         // the per-step action-footer label switches to "Vorläufiges
  139 |         // Ergebnis" so the admin sees the rejection is not yet final.
  140 |         await apps.openApplicationFromList(adminPage, application._id);
  141 |         await apps.expectApplicationStatus(
  142 |           adminPage,
  143 |           application._id,
  144 |           'in_progress'
  145 |         );
  146 |         // Stage-1 result panel rendered. The decision section appears
  147 |         // once the calc preview lands; wait for it explicitly so the
  148 |         // text assertion below does not race a partial render.
  149 |         await expect(
  150 |           adminPage.locator('[data-testid="application-result-decision"]')
  151 |         ).toBeVisible({ timeout: 15_000 });
  152 |         await expect(
  153 |           adminPage.getByText(/Zwischenergebnis|Interim result/)
  154 |         ).toBeVisible({ timeout: 10_000 });
  155 | 
  156 |         // Complete the post-decision step. Engine now finalises the
  157 |         // status to `rejected` because all required steps are done.
  158 |         await actions.driveToFinalStage(adminPage, application);
  159 |         await apps.expectApplicationStatus(
  160 |           adminPage,
  161 |           application._id,
  162 |           'rejected'
  163 |         );
  164 | 
  165 |         // openspec/denormalize-active-step-keys: with every step
  166 |         // completed the trigger drains the denormalised set, so
  167 |         // `activeStageStepKeys` is the empty array (the application
  168 |         // doc still carries the field, just empty).
  169 |         const tokensAfterFinal = await convexRun<string[] | null>(
  170 |           'e2e:getApplicationActiveStageStepKeys',
  171 |           { applicationId: application._id }
  172 |         );
  173 |         expect(tokensAfterFinal).toEqual([]);
  174 | 
  175 |         // Result label switches back to "Endergebnis" once finalised.
  176 |         await expect(
  177 |           adminPage.getByText(/Zwischenergebnis|Interim result/)
  178 |         ).toHaveCount(0, { timeout: 10_000 });
  179 |       } finally {
  180 |         await apps.deleteApplication(application._id);
  181 |       }
  182 |     });
  183 | 
  184 |     test('manual reject → invalid → withdrawn moves through finalised states', async ({
  185 |       adminPage,
  186 |     }) => {
  187 |       const application = await actions.seedApplication();
  188 |       try {
  189 |         await actions.driveToRejectableState(adminPage, application);
  190 | 
  191 |         await apps.setApplicationStatus(
  192 |           adminPage,
  193 |           application._id,
  194 |           'invalid',
  195 |           'invalid for E2E lifecycle'
  196 |         );
  197 |         await apps.expectApplicationStatus(
  198 |           adminPage,
  199 |           application._id,
  200 |           'invalid'
  201 |         );
  202 | 
  203 |         await apps.setApplicationStatus(
  204 |           adminPage,
  205 |           application._id,
  206 |           'withdrawn',
  207 |           'withdrawn for E2E lifecycle'
  208 |         );
  209 |         await apps.expectApplicationStatus(
  210 |           adminPage,
  211 |           application._id,
  212 |           'withdrawn'
  213 |         );
  214 |       } finally {
  215 |         await apps.deleteApplication(application._id);
  216 |       }
  217 |     });
  218 | 
  219 |     test('re-decision flips the status menu label to edit mode and pre-fills the reason', async ({
  220 |       adminPage,
  221 |     }) => {
  222 |       const application = await actions.seedApplication();
  223 |       try {
  224 |         await actions.driveToAcceptableState(adminPage, application);
  225 |         await apps.setApplicationStatus(
  226 |           adminPage,
  227 |           application._id,
  228 |           'rejected',
  229 |           'first reject'
  230 |         );
  231 | 
  232 |         // Reopen the menu: the rejection entry should now read
  233 |         // "Edit rejection reason" because the application carries
  234 |         // `manualDecision.type = 'rejected'`. We match either German
```