# 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: evidence\qe-m1-02-collection-run.spec.ts >> QE-M1-02: Collection Run List & Detail Verification >> QE-M1-02: Collection run renders in list with API-derived expected values
- Location: tests\e2e\evidence\qe-m1-02-collection-run.spec.ts:24:3

# Error details

```
Error: expect(received).toBeTruthy()

Received: false
```

# Page snapshot

```yaml
- generic [active] [ref=e1]:
  - heading "An unhandled exception occurred while processing the request." [level=1] [ref=e2]
  - generic [ref=e3]: "PostgresException: 28P01: password authentication failed for user \"quantengine_app\""
  - paragraph [ref=e4]: Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)
  - list [ref=e5]:
    - listitem [ref=e6] [cursor=pointer]: Stack
    - listitem [ref=e7] [cursor=pointer]: Query
    - listitem [ref=e8] [cursor=pointer]: Cookies
    - listitem [ref=e9] [cursor=pointer]: Headers
    - listitem [ref=e10] [cursor=pointer]: Routing
  - list [ref=e12]:
    - listitem [ref=e13]:
      - 'heading "PostgresException: 28P01: password authentication failed for user \"quantengine_app\"" [level=2] [ref=e14]'
      - list [ref=e15]:
        - listitem [ref=e16]:
          - heading "Npgsql.Internal.NpgsqlConnector.ReadMessageLong(bool async, DataRowLoadingMode dataRowLoadingMode, bool readingNotifications, bool isReadingPrependedMessage)" [level=3] [ref=e17]
        - listitem [ref=e18]:
          - heading "System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder<TResult>+StateMachineBox<TStateMachine>.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(short token)" [level=3] [ref=e19]
        - listitem [ref=e20]:
          - heading "Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List<string> mechanisms, string username, bool async, CancellationToken cancellationToken)" [level=3] [ref=e21]
        - listitem [ref=e22]:
          - heading "Npgsql.Internal.NpgsqlConnector.Authenticate(string username, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e23]
        - listitem [ref=e24]:
          - heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e25]
        - listitem [ref=e26]:
          - heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e27]
        - listitem [ref=e28]:
          - heading "Npgsql.Internal.NpgsqlConnector.<Open>g__OpenCore|209_0(NpgsqlConnector conn, string username, SslMode sslMode, GssEncryptionMode gssEncMode, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e29]
        - listitem [ref=e30]:
          - heading "Npgsql.Internal.NpgsqlConnector.Open(NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e31]
        - listitem [ref=e32]:
          - heading "Npgsql.PoolingDataSource.OpenNewConnector(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e33]
        - listitem [ref=e34]:
          - heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e35]
        - listitem [ref=e36]:
          - heading "Npgsql.PoolingDataSource.<Get>g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e37]
        - listitem [ref=e38]:
          - heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable<TResult>+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e39]
        - listitem [ref=e40]:
          - heading "Npgsql.NpgsqlConnection.<Open>g__OpenAsync|42_0(bool async, CancellationToken cancellationToken)" [level=3] [ref=e41]
        - listitem [ref=e42]:
          - heading "Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in SqlMapper.Async.cs" [level=3] [ref=e43]:
            - text: Dapper.SqlMapper.QueryRowAsync<T>(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in
            - code [ref=e44]: SqlMapper.Async.cs
        - listitem [ref=e45]:
          - heading "QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in WorkspaceRepository.cs" [level=3] [ref=e46]:
            - text: QuantEngine.Infrastructure.Repositories.WorkspaceRepository.GetAccountByUsernameAsync(string username) in
            - code [ref=e47]: WorkspaceRepository.cs
          - button "+" [ref=e48] [cursor=pointer]
          - list [ref=e50]:
            - listitem [ref=e51]: return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
        - listitem [ref=e52]:
          - heading "QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in AuthService.cs" [level=3] [ref=e53]:
            - text: QuantEngine.Web.Services.AuthService.AuthenticateAsync(string username, string password, string ipAddress) in
            - code [ref=e54]: AuthService.cs
          - button "+" [ref=e55] [cursor=pointer]
          - list [ref=e57]:
            - listitem [ref=e58]: var account = await _workspaceRepository.GetAccountByUsernameAsync(username.Trim());
        - listitem [ref=e59]:
          - heading "QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in Login.cshtml.cs" [level=3] [ref=e60]:
            - text: QuantEngine.Web.Pages.Account.LoginModel.OnPostAsync(string username, string password, bool rememberUsername) in
            - code [ref=e61]: Login.cshtml.cs
          - button "+" [ref=e62] [cursor=pointer]
          - list [ref=e64]:
            - listitem [ref=e65]: var account = await _authService.AuthenticateAsync(username, password, ipAddress);
        - listitem [ref=e66]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Convert<T>(object taskAsObject)" [level=3] [ref=e67]
        - listitem [ref=e68]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.ExecutorFactory+GenericTaskHandlerMethod.Execute(object receiver, object[] arguments)" [level=3] [ref=e69]
        - listitem [ref=e70]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeHandlerMethodAsync()" [level=3] [ref=e71]
        - listitem [ref=e72]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeNextPageFilterAsync()" [level=3] [ref=e73]
        - listitem [ref=e74]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Rethrow(PageHandlerExecutedContext context)" [level=3] [ref=e75]
        - listitem [ref=e76]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e77]
        - listitem [ref=e78]:
          - heading "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageActionInvoker.InvokeInnerFilterAsync()" [level=3] [ref=e79]
        - listitem [ref=e80]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e81]
        - listitem [ref=e82]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)" [level=3] [ref=e83]
        - listitem [ref=e84]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(ref State next, ref Scope scope, ref object state, ref bool isCompleted)" [level=3] [ref=e85]
        - listitem [ref=e86]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, object state, bool isCompleted)" [level=3] [ref=e87]
        - listitem [ref=e88]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e89]
        - listitem [ref=e90]:
          - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e91]
        - listitem [ref=e92]:
          - heading "Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|7_0(Endpoint endpoint, Task requestTask, ILogger logger)" [level=3] [ref=e93]
        - listitem [ref=e94]:
          - heading "Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e95]
        - listitem [ref=e96]:
          - heading "Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)" [level=3] [ref=e97]
        - listitem [ref=e98]:
          - heading "Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)" [level=3] [ref=e99]
        - listitem [ref=e100]:
          - heading "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)" [level=3] [ref=e101]
    - listitem [ref=e102]:
      - button "Show raw exception details" [ref=e104] [cursor=pointer]
```

# Test source

```ts
  1   | import { test, expect } from '@playwright/test';
  2   | import * as fs from 'fs';
  3   | import * as path from 'path';
  4   | 
  5   | test.describe('QE-M1-02: Collection Run List & Detail Verification', () => {
  6   |   // Login before each test
  7   |   test.beforeEach(async ({ page }) => {
  8   |     await page.goto('/Account/Login');
  9   |     await page.waitForLoadState('domcontentloaded');
  10  | 
  11  |     // Fill login form with credentials (admin/admin)
  12  |     const usernameInput = page.locator('#username');
  13  |     const passwordInput = page.locator('#password');
  14  |     const loginButton = page.locator('#loginBtn');
  15  | 
  16  |     await usernameInput.fill('admin');
  17  |     await passwordInput.fill('admin');
  18  |     await loginButton.click();
  19  | 
  20  |     // Wait for login to complete
  21  |     await page.waitForLoadState('domcontentloaded');
  22  |   });
  23  | 
  24  |   test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => {
  25  |     // Step 1: Fetch expected values from API (source of truth)
  26  |     const apiResponse = await page.request.get('/api/collection/runs?limit=20');
> 27  |     expect(apiResponse.ok()).toBeTruthy();
      |                              ^ Error: expect(received).toBeTruthy()
  28  | 
  29  |     const responseJson = await apiResponse.json();
  30  |     const runs = (responseJson as any).runs || [];
  31  | 
  32  |     // Fail if no collection runs exist in database
  33  |     if (runs.length === 0) {
  34  |       throw new Error(
  35  |         'No collection runs in DB — run the daily-collection job first. ' +
  36  |         'Expected at least 1 run from kis_collection_runs table.'
  37  |       );
  38  |     }
  39  | 
  40  |     // Extract expected values from most recent run (first in list)
  41  |     const expectedRun = runs[0];
  42  |     const expectedRunId = expectedRun.runId;
  43  |     const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0;
  44  |     const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed"
  45  | 
  46  |     // Map status to Korean text (same logic as Index.cshtml — unknown statuses
  47  |     // like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge)
  48  |     let expectedStatusText = String(expectedStatus ?? '');
  49  |     if (expectedStatus?.toLowerCase() === 'completed') {
  50  |       expectedStatusText = '완료';
  51  |     } else if (expectedStatus?.toLowerCase() === 'running') {
  52  |       expectedStatusText = '진행 중';
  53  |     } else if (expectedStatus?.toLowerCase() === 'failed') {
  54  |       expectedStatusText = '실패';
  55  |     }
  56  | 
  57  |     console.log(
  58  |       `\n=== QE-M1-02 Test Started ===\n` +
  59  |       `Expected RunId: ${expectedRunId}\n` +
  60  |       `Expected TotalSnapshots: ${expectedTotalSnapshots}\n` +
  61  |       `Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n`
  62  |     );
  63  | 
  64  |     // Step 2: Navigate to Collection admin page
  65  |     await page.goto('/Admin/Collection');
  66  |     await page.waitForLoadState('domcontentloaded');
  67  | 
  68  |     // Step 3: Verify page title contains "데이터 수집" (collection)
  69  |     const pageTitle = await page.title();
  70  |     expect(pageTitle).toContain('데이터 수집');
  71  | 
  72  |     // Step 4: Assert that a row containing the expected runId is visible
  73  |     const runIdCell = page.locator(`td:has-text("${expectedRunId}")`);
  74  |     await expect(runIdCell).toBeVisible();
  75  |     console.log(`✓ RunId row found and visible: ${expectedRunId}`);
  76  | 
  77  |     // Step 5: Find the row containing this runId and verify the snapshot count
  78  |     const tableRow = runIdCell.locator('xpath=ancestor::tr');
  79  | 
  80  |     // Within the row, find all td elements and map to columns
  81  |     // Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5)
  82  |     const cells = tableRow.locator('td');
  83  |     const cellCount = await cells.count();
  84  |     expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns
  85  | 
  86  |     // Cell 4 (index 4) is "스냅샷 수" (total snapshots)
  87  |     const snapshotCell = cells.nth(4);
  88  |     const snapshotText = await snapshotCell.textContent();
  89  |     expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots));
  90  |     console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`);
  91  | 
  92  |     // Cell 3 (index 3) is "상태" (status badge)
  93  |     const statusCell = cells.nth(3);
  94  |     const statusBadge = statusCell.locator('span.badge');
  95  |     const statusBadgeText = await statusBadge.textContent();
  96  |     expect(statusBadgeText?.trim()).toBe(expectedStatusText);
  97  |     console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`);
  98  | 
  99  |     // Step 6: Create screenshot directory and take screenshot of collection list
  100 |     const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots');
  101 |     fs.mkdirSync(screenshotDir, { recursive: true });
  102 | 
  103 |     await page.screenshot({
  104 |       path: path.join(screenshotDir, '01-collection-page.png'),
  105 |       fullPage: true,
  106 |     });
  107 |     console.log(`✓ Screenshot saved: 01-collection-page.png`);
  108 | 
  109 |     // Step 7: Navigate to the run detail page
  110 |     // The detail page route is /Admin/Collection/{runId}
  111 |     await page.goto(`/Admin/Collection/${expectedRunId}`);
  112 |     await page.waitForLoadState('domcontentloaded');
  113 | 
  114 |     // Step 8: Verify detail page title contains the runId
  115 |     const detailPageTitle = await page.title();
  116 |     expect(detailPageTitle).toContain('수집 실행 상세');
  117 | 
  118 |     // Step 9: Verify that the RunId is displayed on the detail page
  119 |     // The page title shows: "수집 실행 상세 - {runId}"
  120 |     const pageHeading = page.locator('h2.page-title');
  121 |     const headingText = await pageHeading.textContent();
  122 |     expect(headingText).toContain(expectedRunId);
  123 |     console.log(`✓ Detail page title contains RunId: ${headingText}`);
  124 | 
  125 |     // Step 10: Verify snapshots count is displayed on detail page
  126 |     // The snapshot count appears in a card with "스냅샷 수" as the title
  127 |     const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")');
```