# 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] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [CO] 총판관리 / 수정 모달 마스킹 및 인증 전 수정 차단
- Location: src/qa/scenarios/privacyMaskingRegression.ts:95:11

# Error details

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

# Test source

```ts
  216 |   runtime: RuntimeQaEnv,
  217 |   definition: PrivacyPageDefinition
  218 | ): Promise<DetailResult> {
  219 |   const selector = definition.detailOpenSelector;
  220 |   if (!selector) throw new Error(`${definition.name} 상세 버튼 선택자가 정의되지 않았습니다.`);
  221 |   const opened = await openDetailWithUnmaskButton(page, selector);
  222 |   if (!opened) {
  223 |     const bodyText = normalize(await page.locator("body").innerText());
  224 |     if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
  225 |       blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 상세 검증 데이터가 없습니다.`);
  226 |     }
  227 |     if ((await page.locator(selector).count()) > 0) {
  228 |       blocked(`${runtime.role} ${definition.name} 상세 데이터에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  229 |     }
  230 |     throw new Error(`${runtime.role} ${definition.name} 목록에 데이터가 있지만 상세보기 또는 마스킹 해제 버튼이 없습니다.`);
  231 |   }
  232 |
  233 |   const unmaskButton = opened.button;
  234 |   const surface = opened.surface;
  235 |   const before = normalize(await surface.innerText());
  236 |   const maskedCharacters = countMaskCharacters(before);
  237 |   if (maskedCharacters === 0) {
  238 |     await closeSurface(page, surface);
  239 |     blocked(`${runtime.role} ${definition.name} 상세에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  240 |   }
  241 |
  242 |   await domClick(unmaskButton);
  243 |   const pinInput = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"), 10_000);
  244 |   if (!pinInput) {
  245 |     await closeSurface(page, surface);
  246 |     throw new Error(`${runtime.role} ${definition.name} 마스킹 해제 클릭 후 PIN 인증창이 표시되지 않았습니다.`);
  247 |   }
  248 |   await pinInput.fill(runtime.twoFactorCode ?? "secret-redacted");
  249 |   const authSurface = pinInput.locator("xpath=ancestor::*[@role='dialog'][1]");
  250 |   const confirmRoot = (await authSurface.count()) > 0 ? authSurface : page.locator("body");
  251 |   const confirm = await firstVisible(confirmRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }));
  252 |   if (!confirm) {
  253 |     await closeSurface(page, surface);
  254 |     throw new Error(`${runtime.role} ${definition.name} PIN 인증 확인 버튼을 찾지 못했습니다.`);
  255 |   }
  256 |   await domClick(confirm);
  257 |
  258 |   const unmaskedState = page.getByRole("button", { name: /마스킹 해제됨/ });
  259 |   await expect(unmaskedState, `${runtime.role} ${definition.name} PIN 인증 후 마스킹 해제됨 상태가 표시되어야 합니다.`).toBeVisible({ timeout: 10_000 });
  260 |   const currentDialog = unmaskedState.locator("xpath=ancestor::*[@role='dialog'][1]");
  261 |   const currentSurface = (await currentDialog.count()) > 0 ? currentDialog : page.locator("body");
  262 |   const after = normalize(await currentSurface.innerText({ timeout: 5_000 }));
  263 |   const unmaskedCharacters = countMaskCharacters(after);
  264 |   const result = {
  265 |     maskedCharacters,
  266 |     unmaskedCharacters,
  267 |     beforeLength: before.length,
  268 |     afterLength: after.length
  269 |   };
  270 |
  271 |   await closeSurface(page, currentSurface);
  272 |   expect(result.afterLength, `${runtime.role} ${definition.name} 원문 전환 후 상세 값이 유지되어야 합니다.`).toBeGreaterThan(0);
  273 |   return result;
  274 | }
  275 |
  276 | async function openDetailWithUnmaskButton(
  277 |   page: Page,
  278 |   selector: string
  279 | ): Promise<{ button: Locator; surface: Locator } | undefined> {
  280 |   const root = await interactionRoot(page);
  281 |   const candidates = root.locator(selector);
  282 |   const count = Math.min(await candidates.count(), 12);
  283 |   for (let index = 0; index < count; index += 1) {
  284 |     const candidate = candidates.nth(index);
  285 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  286 |     if (!(await isActiveElement(candidate))) continue;
  287 |     await domClick(candidate);
  288 |     await page.waitForTimeout(700);
  289 |     // Detail dialogs are rendered in a portal outside the keep-alive route.
  290 |     const button = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  291 |     if (button) {
  292 |       const dialog = button.locator("xpath=ancestor::*[@role='dialog'][1]");
  293 |       const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
  294 |       const detailText = normalize(await surface.innerText().catch(() => ""));
  295 |       if (countMaskCharacters(detailText) > 0) {
  296 |         return { button, surface };
  297 |       }
  298 |       await closeSurface(page, surface);
  299 |       continue;
  300 |     }
  301 |     await page.keyboard.press("Escape").catch(() => undefined);
  302 |   }
  303 |   return undefined;
  304 | }
  305 |
  306 | async function verifyEditMasking(
  307 |   page: Page,
  308 |   definition: PrivacyPageDefinition
  309 | ): Promise<{ maskedInputs: number; editableMaskedInputs: number }> {
  310 |   const opened = await openEditModal(page);
  311 |   if (!opened) {
  312 |     const bodyText = normalize(await page.locator("body").innerText());
  313 |     if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
  314 |       blocked(`${definition.name}은 기간 확장 후에도 수정 모달 검증 데이터가 없습니다.`);
  315 |     }
> 316 |     throw new Error(`${definition.name} 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.`);
      |           ^ Error: 총판관리 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.
  317 |   }
  318 |
  319 |   const modalText = normalize(await opened.surface.innerText());
  320 |   expect(modalText, `${definition.name} 수정 모달에 마스킹 해제 기능이 있어야 합니다.`).toMatch(/마스킹 해제/);
  321 |
  322 |   const inputs = opened.surface.locator("input, textarea");
  323 |   const inputCount = await inputs.count();
  324 |   let maskedInputs = 0;
  325 |   let editableMaskedInputs = 0;
  326 |   for (let index = 0; index < inputCount; index += 1) {
  327 |     const input = inputs.nth(index);
  328 |     const value = await input.inputValue().catch(() => "");
  329 |     if (!/[＊*•]/.test(value)) continue;
  330 |     maskedInputs += 1;
  331 |     if (await input.isEditable().catch(() => false)) editableMaskedInputs += 1;
  332 |   }
  333 |
  334 |   await closeSurface(page, opened.surface);
  335 |   if (maskedInputs === 0) {
  336 |     blocked(`${definition.name} 수정 모달에 수정 차단을 검증할 마스킹 입력값이 없습니다.`);
  337 |   }
  338 |   expect(editableMaskedInputs, `${definition.name} 수정 모달의 마스킹 개인정보는 인증 전에 편집할 수 없어야 합니다.`).toBe(0);
  339 |   return { maskedInputs, editableMaskedInputs };
  340 | }
  341 |
  342 | async function openEditModal(page: Page): Promise<{ surface: Locator } | undefined> {
  343 |   const root = await interactionRoot(page);
  344 |   const editCandidates = root.locator(
  345 |     "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), svg.lucide-pencil"
  346 |   );
  347 |   const count = Math.min(await editCandidates.count(), 20);
  348 |   for (let index = 0; index < count; index += 1) {
  349 |     const candidate = editCandidates.nth(index);
  350 |     if (!(await candidate.isVisible().catch(() => false))) continue;
  351 |     if (!(await isActiveElement(candidate))) continue;
  352 |     await candidate.click({ force: true });
  353 |     await page.waitForTimeout(700);
  354 |     const unmask = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
  355 |     if (unmask) {
  356 |       const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
  357 |       return { surface: (await dialog.count()) > 0 ? dialog : page.locator("body") };
  358 |     }
  359 |     await page.keyboard.press("Escape").catch(() => undefined);
  360 |   }
  361 |   return undefined;
  362 | }
  363 |
  364 | async function verifyExcelMasking(
  365 |   page: Page,
  366 |   runtime: RuntimeQaEnv,
  367 |   definition: PrivacyPageDefinition
  368 | ): Promise<{
  369 |   masked: ExcelDocument;
  370 |   unmasked: ExcelDocument;
  371 |   maskedStars: number;
  372 |   unmaskedStars: number;
  373 | }> {
  374 |   if (!(await hasActionableRows(page))) {
  375 |     blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 Excel 검증 데이터가 없습니다.`);
  376 |   }
  377 |
  378 |   const maskedRequest = await requestExcelDocument(page, runtime, definition, false);
  379 |   await openTargetPage(page, runtime, definition);
  380 |   await ensureSearchData(page);
  381 |   const unmaskedRequest = await requestExcelDocument(page, runtime, definition, true);
  382 |
  383 |   const documents = await waitForExcelDocuments(page, runtime, [maskedRequest.id, unmaskedRequest.id]);
  384 |   const masked = requireDocument(documents, maskedRequest.id);
  385 |   const unmasked = requireDocument(documents, unmaskedRequest.id);
  386 |   expect(normalizeMode(masked.exposureMode), `${definition.name} 기본 Excel은 MASKED여야 합니다.`).toBe("MASKED");
  387 |   expect(normalizeMode(unmasked.exposureMode), `${definition.name} 원문 Excel은 UNMASKED여야 합니다.`).toBe("UNMASKED");
  388 |
  389 |   const maskedBuffer = await downloadDocument(page, masked);
  390 |   const unmaskedBuffer = await downloadDocument(page, unmasked);
  391 |   const maskedText = extractDownloadBufferText(maskedBuffer, masked.filename ?? "masked.xlsx");
  392 |   const unmaskedText = extractDownloadBufferText(unmaskedBuffer, unmasked.filename ?? "unmasked.xlsx");
  393 |   const maskedStars = countMaskCharacters(maskedText);
  394 |   const unmaskedStars = countMaskCharacters(unmaskedText);
  395 |
  396 |   if (maskedStars === 0) {
  397 |     blocked(`${runtime.role} ${definition.name} 기본 Excel에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  398 |   }
  399 |   expect(unmaskedStars, `${definition.name} 원문 Excel은 기본 Excel보다 마스킹 문자가 적어야 합니다.`).toBeLessThan(maskedStars);
  400 |   return { masked, unmasked, maskedStars, unmaskedStars };
  401 | }
  402 |
  403 | async function requestExcelDocument(
  404 |   page: Page,
  405 |   runtime: RuntimeQaEnv,
  406 |   definition: PrivacyPageDefinition,
  407 |   unmasked: boolean
  408 | ): Promise<ExcelRequestResult> {
  409 |   const interaction = await interactionRoot(page);
  410 |   const excelButton = await firstVisible(interaction.locator("button:visible").filter({ hasText: /^엑셀$/ }));
  411 |   if (!excelButton) {
  412 |     throw new Error(`${runtime.role} ${definition.name} 화면에 Excel 버튼이 표시되지 않습니다.`);
  413 |   }
  414 |   await domClick(excelButton);
  415 |   await page.waitForTimeout(300);
  416 |
```