# 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:105:11

# Error details

```
Error: 가맹점관리 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.
```

# Test source

```ts
  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);
  345 |       clicked = true;
  346 |       break;
  347 |     }
  348 |     if (!clicked) return;
  349 |   }
  350 | }
  351 |
  352 | async function verifyEditMasking(
  353 |   page: Page,
  354 |   runtime: RuntimeQaEnv,
  355 |   definition: PrivacyPageDefinition
  356 | ): Promise<{ maskedInputs: number; disabledBefore: boolean; enabledAfter: boolean }> {
  357 |   const preferredIndex = await findBestEditCandidateIndex(page);
  358 |   const opened = await openEditModal(page, preferredIndex);
  359 |   if (!opened) {
  360 |     const bodyText = normalize(await page.locator("body").innerText());
  361 |     if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
  362 |       blocked(`${definition.name}은 기간 확장 후에도 수정 모달 검증 데이터가 없습니다.`);
  363 |     }
> 364 |     throw new Error(`${definition.name} 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.`);
      |           ^ Error: 가맹점관리 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.
  365 |   }
  366 |
  367 |   const modalText = normalize(await opened.surface.innerText());
  368 |   expect(modalText, `${definition.name} 수정 모달에 마스킹 해제 기능이 있어야 합니다.`).toMatch(/마스킹 해제/);
  369 |
  370 |   const inputs = opened.surface.locator("input, textarea");
  371 |   const inputCount = await inputs.count();
  372 |   let maskedInputs = 0;
  373 |   for (let index = 0; index < inputCount; index += 1) {
  374 |     const input = inputs.nth(index);
  375 |     const value = await input.inputValue().catch(() => "");
  376 |     if (!/[＊*•]/.test(value)) continue;
  377 |     maskedInputs += 1;
  378 |   }
  379 |
  380 |   if (maskedInputs === 0 && !definition.optionalMasking) {
  381 |     await closeSurface(page, opened.surface);
  382 |     blocked(`${definition.name} 수정 모달에 수정 차단을 검증할 마스킹 입력값이 없습니다.`);
  383 |   }
  384 |
  385 |   const submit = await findEditSubmitButton(opened.surface);
  386 |   if (!submit) {
  387 |     await closeSurface(page, opened.surface);
  388 |     throw new Error(`${definition.name} 수정 모달에서 수정 또는 저장 버튼을 찾지 못했습니다.`);
  389 |   }
  390 |   const disabledBefore = await isControlDisabled(submit);
  391 |   expect(disabledBefore, `${definition.name} 수정 버튼은 마스킹 해제 전에 disabled 상태여야 합니다.`).toBe(true);
  392 |
  393 |   const unmask = await firstVisible(opened.surface.getByRole("button", { name: /^마스킹 해제$/ }));
  394 |   if (!unmask) {
  395 |     await closeSurface(page, opened.surface);
  396 |     throw new Error(`${definition.name} 수정 모달에서 마스킹 해제 버튼을 찾지 못했습니다.`);
  397 |   }
  398 |   await domClick(unmask);
  399 |   const pin = await findPinInput(page, 10_000);
  400 |   if (!pin) {
  401 |     await closeSurface(page, opened.surface);
  402 |     throw new Error(`${definition.name} 수정 모달 마스킹 해제 후 PIN 인증창이 표시되지 않았습니다.`);
  403 |   }
  404 |   await enterPin(pin, runtime.twoFactorCode ?? "secret-redacted");
  405 |   const pinRoot = await pinConfirmationRoot(page, pin);
  406 |   const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  407 |   if (!confirm) {
  408 |     await closeSurface(page, opened.surface);
  409 |     throw new Error(`${definition.name} 수정 모달 PIN 확인 버튼을 찾지 못했습니다.`);
  410 |   }
  411 |   await expect(confirm, `${definition.name} 수정 모달 PIN 확인 버튼이 활성화되어야 합니다.`).toBeEnabled();
  412 |   await domClick(confirm);
  413 |   await expect.poll(async () => {
  414 |     const candidate = await findEditSubmitButton(opened.surface);
  415 |     return candidate ? !(await isControlDisabled(candidate)) : false;
  416 |   }, {
  417 |     message: `${definition.name} 수정 버튼은 PIN 마스킹 해제 후 활성화되어야 합니다.`,
  418 |     timeout: 10_000
  419 |   }).toBe(true);
  420 |
  421 |   const refreshedSubmit = await findEditSubmitButton(opened.surface);
  422 |   if (!refreshedSubmit) {
  423 |     await closeSurface(page, opened.surface);
  424 |     throw new Error(`${definition.name} PIN 인증 후 수정 또는 저장 버튼을 다시 찾지 못했습니다.`);
  425 |   }
  426 |   const enabledAfter = !(await isControlDisabled(refreshedSubmit));
  427 |   await closeSurface(page, opened.surface);
  428 |   return { maskedInputs, disabledBefore, enabledAfter };
  429 | }
  430 |
  431 | async function openEditModal(page: Page, preferredIndex?: number): Promise<{ surface: Locator } | undefined> {
  432 |   const root = await interactionRoot(page);
  433 |   const editCandidates = root.locator(
  434 |     "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), button:has(svg.lucide-book-open), button:has(svg.lucide-book), svg.lucide-pencil, svg.lucide-book-open, svg.lucide-book"
  435 |   );
  436 |   const count = Math.min(await editCandidates.count(), 20);
  437 |   const indexes = preferredIndex === undefined
  438 |     ? Array.from({ length: count }, (_, index) => index)
  439 |     : [preferredIndex];
  440 |   for (const index of indexes) {
  441 |     const candidate = await clickableCandidate(editCandidates.nth(index));
  442 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  443 |     if (!(await isActiveElement(candidate))) continue;
  444 |     await candidate.click({ force: true });
  445 |     await page.waitForTimeout(700);
  446 |     const unmask = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  447 |     if (unmask) {
  448 |       const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
  449 |       return { surface: (await dialog.count()) > 0 ? dialog : page.locator("body") };
  450 |     }
  451 |     await page.keyboard.press("Escape").catch(() => undefined);
  452 |   }
  453 |   return undefined;
  454 | }
  455 |
  456 | async function findBestEditCandidateIndex(page: Page): Promise<number | undefined> {
  457 |   const root = await interactionRoot(page);
  458 |   const candidates = root.locator(
  459 |     "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), button:has(svg.lucide-book-open), button:has(svg.lucide-book), svg.lucide-pencil, svg.lucide-book-open, svg.lucide-book"
  460 |   );
  461 |   const count = Math.min(await candidates.count(), 12);
  462 |   let bestIndex: number | undefined;
  463 |   let bestScore = Number.NEGATIVE_INFINITY;
  464 |   for (let index = 0; index < count; index += 1) {
```