# 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: 4147/dev/privacy-masking-regression.spec.ts >> [4147][dev] [재QA] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [ADMIN] 법인계좌 입출금 내역 / 상세 마스킹 및 PIN 원문 조회
- Location: src/qa/scenarios/privacyMaskingRegression.ts:84:9

# Error details

```
Error: ADMIN 법인계좌 입출금 내역 목록에 데이터가 있지만 상세보기 또는 마스킹 해제 버튼이 없습니다.
```

# Test source

```ts
  144 |               ]);
  145 |             }, true);
  146 |           });
  147 |         }
  148 |       }
  149 |     }
  150 |   });
  151 | }
  152 |
  153 | async function withRolePage(
  154 |   browser: Browser,
  155 |   runtime: RuntimeQaEnv,
  156 |   callback: (page: Page) => Promise<void>,
  157 |   acceptDownloads = false
  158 | ): Promise<void> {
  159 |   const options = runtime.storageState
  160 |     ? { storageState: runtime.storageState, acceptDownloads }
  161 |     : { acceptDownloads };
  162 |   const context = await browser.newContext(options);
  163 |   const page = await context.newPage();
  164 |   try {
  165 |     await callback(page);
  166 |   } finally {
  167 |     await context.close().catch(() => undefined);
  168 |   }
  169 | }
  170 |
  171 | async function openTargetPage(page: Page, runtime: RuntimeQaEnv, definition: PrivacyPageDefinition): Promise<void> {
  172 |   if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 설정되지 않았습니다.`);
  173 |   const url = new URL(definition.path, ensureTrailingSlash(runtime.baseUrl!));
  174 |   if (definition.tenantParam && runtime.tenantId) {
  175 |     url.searchParams.set(definition.tenantParam, runtime.tenantId);
  176 |   }
  177 |
  178 |   await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
  179 |   await settle(page);
  180 |   if (await isLoginPage(page, runtime) || /\/auth\/login(?:$|[?#])/.test(page.url()) || await page.getByRole("button", { name: /로그인/ }).isVisible().catch(() => false)) {
  181 |     await loginWithCredentials(page, runtime);
  182 |     await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
  183 |     await settle(page);
  184 |   }
  185 |
  186 |   const root = await interactionRoot(page);
  187 |   const screenText = normalize(await root.innerText({ timeout: 10_000 }).catch(() => ""));
  188 |   const bodyText = normalize(await page.locator("body").innerText({ timeout: 10_000 }).catch(() => ""));
  189 |   if (/cloudflare|sorry, you have been blocked|attention required/i.test(bodyText)) {
  190 |     blocked(`${runtime.role} ${definition.name} 접근이 Cloudflare에서 차단되었습니다.`);
  191 |   }
  192 |   expect(screenText, `${runtime.role} ${definition.name} 화면 접근 권한이 있어야 합니다.`).not.toMatch(PERMISSION_DENIED_TEXT);
  193 |   expect(await isLoginPage(page, runtime), `${runtime.role} ${definition.name} 접근 중 로그인 화면으로 돌아가면 안 됩니다.`).toBe(false);
  194 | }
  195 |
  196 | async function ensureSearchData(page: Page, definition: PrivacyPageDefinition): Promise<void> {
  197 |   await waitForLoading(page);
  198 |   if (!definition.fixedFrom && await hasActionableRows(page)) return;
  199 |
  200 |   const root = await interactionRoot(page);
  201 |   const visibleInputs = root.locator("input:visible");
  202 |   const count = await visibleInputs.count();
  203 |   const dateInputs: Locator[] = [];
  204 |   const monthInputs: Locator[] = [];
  205 |   for (let index = 0; index < count; index += 1) {
  206 |     const input = visibleInputs.nth(index);
  207 |     const type = (await input.getAttribute("type"))?.toLowerCase();
  208 |     const placeholder = (await input.getAttribute("placeholder")) ?? "";
  209 |     if (type === "date" || /날짜|YYYY-MM-DD/i.test(placeholder)) dateInputs.push(input);
  210 |     if (type === "month" || /YYYY-MM/i.test(placeholder)) monthInputs.push(input);
  211 |   }
  212 |
  213 |   if (dateInputs[0]) await setInputValue(dateInputs[0], definition.fixedFrom ?? FALLBACK_FROM);
  214 |   if (dateInputs[1]) await setInputValue(dateInputs[1], definition.fixedTo ?? FALLBACK_TO);
  215 |   for (const input of monthInputs) await setInputValue(input, FALLBACK_MONTH);
  216 |
  217 |   const search = await firstVisible(root.locator("button:visible").filter({ hasText: /^(검색|조회)$/ }));
  218 |   if (search) {
  219 |     await domClick(search);
  220 |     await settle(page);
  221 |     await waitForLoading(page);
  222 |   }
  223 | }
  224 |
  225 | async function verifyDetailMasking(
  226 |   page: Page,
  227 |   runtime: RuntimeQaEnv,
  228 |   definition: PrivacyPageDefinition
  229 | ): Promise<DetailResult> {
  230 |   const selector = definition.detailOpenSelector;
  231 |   if (!selector) throw new Error(`${definition.name} 상세 버튼 선택자가 정의되지 않았습니다.`);
  232 |   if (definition.detailStrategy === "master-account") {
  233 |     await expandMasterAccountRows(page);
  234 |   }
  235 |   const opened = await openDetailWithUnmaskButton(page, selector, Boolean(definition.optionalMasking));
  236 |   if (!opened) {
  237 |     const bodyText = normalize(await page.locator("body").innerText());
  238 |     if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
  239 |       blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 상세 검증 데이터가 없습니다.`);
  240 |     }
  241 |     if ((await page.locator(selector).count()) > 0) {
  242 |       blocked(`${runtime.role} ${definition.name} 상세 데이터에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  243 |     }
> 244 |     throw new Error(`${runtime.role} ${definition.name} 목록에 데이터가 있지만 상세보기 또는 마스킹 해제 버튼이 없습니다.`);
      |           ^ Error: ADMIN 법인계좌 입출금 내역 목록에 데이터가 있지만 상세보기 또는 마스킹 해제 버튼이 없습니다.
  245 |   }
  246 |
  247 |   const unmaskButton = opened.button;
  248 |   const surface = opened.surface;
  249 |   const before = normalize(await surface.innerText());
  250 |   const maskedCharacters = countMaskCharacters(before);
  251 |   if (maskedCharacters === 0 && !definition.optionalMasking) {
  252 |     await closeSurface(page, surface);
  253 |     blocked(`${runtime.role} ${definition.name} 상세에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  254 |   }
  255 |   if (maskedCharacters === 0 && definition.optionalMasking) {
  256 |     const result = {
  257 |       maskedCharacters: 0,
  258 |       unmaskedCharacters: 0,
  259 |       beforeLength: before.length,
  260 |       afterLength: before.length
  261 |     };
  262 |     await closeSurface(page, surface);
  263 |     return result;
  264 |   }
  265 |
  266 |   await domClick(unmaskButton);
  267 |   const pinInput = await findPinInput(page, 10_000);
  268 |   if (!pinInput) {
  269 |     await closeSurface(page, surface);
  270 |     throw new Error(`${runtime.role} ${definition.name} 마스킹 해제 클릭 후 PIN 인증창이 표시되지 않았습니다.`);
  271 |   }
  272 |   await enterPin(pinInput, runtime.twoFactorCode ?? "secret-redacted");
  273 |   const confirmRoot = await pinConfirmationRoot(page, pinInput);
  274 |   const confirm = await firstVisible(confirmRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }));
  275 |   if (!confirm) {
  276 |     await closeSurface(page, surface);
  277 |     throw new Error(`${runtime.role} ${definition.name} PIN 인증 확인 버튼을 찾지 못했습니다.`);
  278 |   }
  279 |   await expect(confirm, `${runtime.role} ${definition.name} PIN 확인 버튼이 활성화되어야 합니다.`).toBeEnabled();
  280 |   await domClick(confirm);
  281 |
  282 |   const unmaskedState = page.getByRole("button", { name: /마스킹 해제됨/ });
  283 |   await expect(unmaskedState, `${runtime.role} ${definition.name} PIN 인증 후 마스킹 해제됨 상태가 표시되어야 합니다.`)
  284 |     .toBeVisible({ timeout: 10_000 });
  285 |   const currentDialog = unmaskedState.locator("xpath=ancestor::*[@role='dialog'][1]");
  286 |   const currentSurface = (await currentDialog.count()) > 0 ? currentDialog : surface;
  287 |   const after = normalize(await currentSurface.innerText({ timeout: 5_000 }));
  288 |   const unmaskedCharacters = countMaskCharacters(after);
  289 |   const result = {
  290 |     maskedCharacters,
  291 |     unmaskedCharacters,
  292 |     beforeLength: before.length,
  293 |     afterLength: after.length
  294 |   };
  295 |
  296 |   await closeSurface(page, currentSurface);
  297 |   expect(result.afterLength, `${runtime.role} ${definition.name} 원문 전환 후 상세 값이 유지되어야 합니다.`).toBeGreaterThan(0);
  298 |   return result;
  299 | }
  300 |
  301 | async function openDetailWithUnmaskButton(
  302 |   page: Page,
  303 |   selector: string,
  304 |   optionalMasking: boolean
  305 | ): Promise<{ button: Locator; surface: Locator } | undefined> {
  306 |   const root = await interactionRoot(page);
  307 |   const candidates = root.locator(selector);
  308 |   const count = Math.min(await candidates.count(), 30);
  309 |   for (let index = 0; index < count; index += 1) {
  310 |     const candidate = candidates.nth(index);
  311 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  312 |     if (!(await isActiveElement(candidate))) continue;
  313 |     await domClick(candidate);
  314 |     await page.waitForTimeout(700);
  315 |     // Detail dialogs are rendered in a portal outside the keep-alive route.
  316 |     const button = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  317 |     if (button) {
  318 |       const dialog = button.locator("xpath=ancestor::*[@role='dialog'][1]");
  319 |       const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
  320 |       const detailText = normalize(await surface.innerText().catch(() => ""));
  321 |       if (optionalMasking || countMaskCharacters(detailText) > 0) {
  322 |         return { button, surface };
  323 |       }
  324 |       await closeSurface(page, surface);
  325 |       continue;
  326 |     }
  327 |     await page.keyboard.press("Escape").catch(() => undefined);
  328 |   }
  329 |   return undefined;
  330 | }
  331 |
  332 | async function expandMasterAccountRows(page: Page): Promise<void> {
  333 |   const root = await interactionRoot(page);
  334 |   for (let depth = 0; depth < 3; depth += 1) {
  335 |     const chevrons = root.locator("svg.lucide-chevron-right:visible");
  336 |     const count = await chevrons.count();
  337 |     if (count === 0) return;
  338 |     let clicked = false;
  339 |     for (let index = 0; index < Math.min(count, 8); index += 1) {
  340 |       const button = chevrons.nth(index).locator("xpath=ancestor::button[1]");
  341 |       if (!(await button.isVisible().catch(() => false))) continue;
  342 |       if (!(await isActiveElement(button))) continue;
  343 |       await domClick(button);
  344 |       await page.waitForTimeout(500);
```