# 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+StateMachineBox.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(short token)" [level=3] [ref=e19] - listitem [ref=e20]: - heading "Npgsql.Internal.NpgsqlConnector.AuthenticateSASL(List 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.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.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.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+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e35] - listitem [ref=e36]: - heading "Npgsql.PoolingDataSource.g__RentAsync|33_0(NpgsqlConnection conn, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)" [level=3] [ref=e37] - listitem [ref=e38]: - heading "System.Runtime.CompilerServices.ConfiguredValueTaskAwaitable+ConfiguredValueTaskAwaiter.GetResult()" [level=3] [ref=e39] - listitem [ref=e40]: - heading "Npgsql.NpgsqlConnection.g__OpenAsync|42_0(bool async, CancellationToken cancellationToken)" [level=3] [ref=e41] - listitem [ref=e42]: - heading "Dapper.SqlMapper.QueryRowAsync(IDbConnection cnn, Row row, Type effectiveType, CommandDefinition command) in SqlMapper.Async.cs" [level=3] [ref=e43]: - text: Dapper.SqlMapper.QueryRowAsync(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(@" - 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(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.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.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.g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e89] - listitem [ref=e90]: - heading "Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Logged|17_1(ResourceInvoker invoker)" [level=3] [ref=e91] - listitem [ref=e92]: - heading "Microsoft.AspNetCore.Routing.EndpointMiddleware.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("스냅샷 수")'); ```