# 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] 부서별 운영상태관리 / Excel 기본 마스킹 및 PIN 원문 다운로드
- Location: src/qa/scenarios/privacyMaskingRegression.ts:127:11

# Error details

```
Error: CO 부서별 운영상태관리 화면에 Excel 버튼이 표시되지 않습니다.
```

# Test source

```ts
  471 |     if (!unmask) {
  472 |       await page.keyboard.press("Escape").catch(() => undefined);
  473 |       continue;
  474 |     }
  475 |     const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
  476 |     const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
  477 |     const inputs = surface.locator("input:visible, textarea:visible");
  478 |     const inputCount = await inputs.count();
  479 |     let nonEmpty = 0;
  480 |     let empty = 0;
  481 |     for (let inputIndex = 0; inputIndex < inputCount; inputIndex += 1) {
  482 |       const input = inputs.nth(inputIndex);
  483 |       const value = normalize(await input.inputValue().catch(() => ""));
  484 |       if (value) nonEmpty += 1;
  485 |       else empty += 1;
  486 |     }
  487 |     const score = nonEmpty * 10 - empty * 20;
  488 |     if (score > bestScore) {
  489 |       bestScore = score;
  490 |       bestIndex = index;
  491 |     }
  492 |     await closeSurface(page, surface);
  493 |     await page.waitForTimeout(200);
  494 |   }
  495 |   return bestIndex;
  496 | }
  497 |
  498 | async function clickableCandidate(candidate: Locator): Promise<Locator> {
  499 |   const tag = await candidate.evaluate((element) => element.tagName.toLowerCase()).catch(() => "");
  500 |   if (tag !== "svg") return candidate;
  501 |   const button = candidate.locator("xpath=ancestor::button[1]");
  502 |   return (await button.count()) > 0 ? button : candidate;
  503 | }
  504 |
  505 | async function findEditSubmitButton(surface: Locator): Promise<Locator | undefined> {
  506 |   return firstVisible(
  507 |     surface.locator("button:visible").filter({ hasText: /^(수정|저장)$/ }),
  508 |     3_000
  509 |   );
  510 | }
  511 |
  512 | async function isControlDisabled(control: Locator): Promise<boolean> {
  513 |   if (await control.isDisabled().catch(() => false)) return true;
  514 |   return control.evaluate((element) => {
  515 |     const target = element as HTMLButtonElement;
  516 |     return target.disabled || element.getAttribute("aria-disabled") === "true" || element.hasAttribute("disabled");
  517 |   }).catch(() => false);
  518 | }
  519 |
  520 | async function verifyExcelMasking(
  521 |   page: Page,
  522 |   runtime: RuntimeQaEnv,
  523 |   definition: PrivacyPageDefinition
  524 | ): Promise<{
  525 |   masked: ExcelDocument;
  526 |   unmasked: ExcelDocument;
  527 |   maskedStars: number;
  528 |   unmaskedStars: number;
  529 | }> {
  530 |   if (!(await hasActionableRows(page))) {
  531 |     blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 Excel 검증 데이터가 없습니다.`);
  532 |   }
  533 |
  534 |   const maskedRequest = await requestExcelDocument(page, runtime, definition, false);
  535 |   await openTargetPage(page, runtime, definition);
  536 |   await ensureSearchData(page, definition);
  537 |   // Document filenames are timestamped to the second. Keep MASKED and
  538 |   // UNMASKED exports distinct so the repository UI can select each file.
  539 |   await page.waitForTimeout(1_200);
  540 |   const unmaskedRequest = await requestExcelDocument(page, runtime, definition, true);
  541 |
  542 |   const repository = await waitForExcelDocuments(page, runtime, definition, [maskedRequest.id, unmaskedRequest.id]);
  543 |   const masked = requireDocument(repository.documents, maskedRequest.id);
  544 |   const unmasked = requireDocument(repository.documents, unmaskedRequest.id);
  545 |   expect(normalizeMode(masked.exposureMode), `${definition.name} 기본 Excel은 MASKED여야 합니다.`).toBe("MASKED");
  546 |   expect(normalizeMode(unmasked.exposureMode), `${definition.name} 원문 Excel은 UNMASKED여야 합니다.`).toBe("UNMASKED");
  547 |
  548 |   const maskedBuffer = await downloadDocument(repository.page, runtime, masked);
  549 |   const unmaskedBuffer = await downloadDocument(repository.page, runtime, unmasked);
  550 |   const maskedText = extractDownloadBufferText(maskedBuffer, masked.filename ?? "masked.xlsx");
  551 |   const unmaskedText = extractDownloadBufferText(unmaskedBuffer, unmasked.filename ?? "unmasked.xlsx");
  552 |   const maskedStars = countMaskCharacters(maskedText);
  553 |   const unmaskedStars = countMaskCharacters(unmaskedText);
  554 |
  555 |   if (maskedStars === 0) {
  556 |     blocked(`${runtime.role} ${definition.name} 기본 Excel에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  557 |   }
  558 |   expect(unmaskedStars, `${definition.name} 원문 Excel은 기본 Excel보다 마스킹 문자가 적어야 합니다.`).toBeLessThan(maskedStars);
  559 |   return { masked, unmasked, maskedStars, unmaskedStars };
  560 | }
  561 |
  562 | async function requestExcelDocument(
  563 |   page: Page,
  564 |   runtime: RuntimeQaEnv,
  565 |   definition: PrivacyPageDefinition,
  566 |   unmasked: boolean
  567 | ): Promise<ExcelRequestResult> {
  568 |   const interaction = await interactionRoot(page);
  569 |   const excelButton = await firstVisible(interaction.locator("button:visible").filter({ hasText: /^엑셀$/ }));
  570 |   if (!excelButton) {
> 571 |     throw new Error(`${runtime.role} ${definition.name} 화면에 Excel 버튼이 표시되지 않습니다.`);
      |           ^ Error: CO 부서별 운영상태관리 화면에 Excel 버튼이 표시되지 않습니다.
  572 |   }
  573 |   await domClick(excelButton);
  574 |   await page.waitForTimeout(300);
  575 |
  576 |   let dialog = await findExcelDialog(page);
  577 |   if (!dialog) {
  578 |     const menuPattern = definition.excelMenuText
  579 |       ? new RegExp(escapeRegExp(definition.excelMenuText).replace(/\\ /g, "\\s*"))
  580 |       : /조회결과\s*받기|조회결과\s*다운로드|가맹점\s*엑셀\s*다운로드|사용자\s*일괄등록\s*양식\s*받기|엑셀\s*다운로드/;
  581 |     const menu = await firstVisible(
  582 |       page.locator("li:visible, [role='menuitem']:visible, button:visible, a:visible")
  583 |         .filter({ hasText: menuPattern })
  584 |     );
  585 |     if (!menu) throw new Error(`${runtime.role} ${definition.name} Excel 조회결과 다운로드 메뉴가 없습니다.`);
  586 |     await domClick(menu);
  587 |     dialog = await findExcelDialog(page, 10_000);
  588 |   }
  589 |   if (!dialog) throw new Error(`${definition.name} Excel 마스킹 안내 모달이 표시되지 않았습니다.`);
  590 |   const root = dialog;
  591 |   const checkbox = root.getByLabel(/마스킹 해제 후 다운로드/).last();
  592 |   if (unmasked) {
  593 |     await expect(checkbox, `${definition.name} Excel 원문 다운로드 선택 항목이 표시되어야 합니다.`).toBeVisible();
  594 |     await checkbox.check();
  595 |   }
  596 |
  597 |   const responses: Response[] = [];
  598 |   const observedRequests: string[] = [];
  599 |   const requestListener = (request: { method: () => string; url: () => string }): void => {
  600 |     if (/excel|masking|authentication/i.test(request.url())) {
  601 |       observedRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
  602 |     }
  603 |   };
  604 |   const listener = (response: Response): void => {
  605 |     const request = response.request();
  606 |     if (!/^(GET|POST)$/.test(request.method())) return;
  607 |     const url = new URL(response.url());
  608 |     if (!/^\/api\/v1\/excel(?:\/[^?]+)?$/.test(url.pathname)) return;
  609 |     if (url.pathname === "/api/v1/excel" && request.method() === "GET") return;
  610 |     if (/export-authorizations/.test(url.pathname)) return;
  611 |     responses.push(response);
  612 |   };
  613 |   page.on("request", requestListener);
  614 |   page.on("response", listener);
  615 |   try {
  616 |     const downloadButton = await firstVisible(root.getByRole("button", { name: /다운로드/ }));
  617 |     if (!downloadButton) throw new Error(`${definition.name} Excel 다운로드 실행 버튼을 찾지 못했습니다.`);
  618 |     await domClick(downloadButton);
  619 |
  620 |     if (unmasked) {
  621 |       const pin = await findPinInput(page, 10_000);
  622 |       if (!pin) throw new Error(`${definition.name} 원문 Excel 선택 후 PIN 인증창이 표시되지 않았습니다.`);
  623 |       await enterPin(pin, runtime.twoFactorCode ?? "secret-redacted");
  624 |       const pinRoot = await pinConfirmationRoot(page, pin);
  625 |       const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  626 |       if (!confirm) {
  627 |         const buttonNames = (await pinRoot.locator("button:visible").allTextContents()).map(normalize).filter(Boolean);
  628 |         throw new Error(`${definition.name} 원문 Excel PIN 확인 버튼을 찾지 못했습니다. 표시 버튼=${buttonNames.join(" | ")}`);
  629 |       }
  630 |       await expect(confirm, `${definition.name} 원문 Excel PIN 확인 버튼이 활성화되어야 합니다.`).toBeEnabled();
  631 |       await domClick(confirm);
  632 |     }
  633 |
  634 |     const response = await waitForCollectedExcelResponse(page, responses, observedRequests);
  635 |     const json = await response.json().catch(() => undefined);
  636 |     const id = findDocumentId(json);
  637 |     if (!id) throw new Error(`${definition.name} Excel 생성 응답에서 문서 ID를 찾지 못했습니다.`);
  638 |     await closeVisibleDialogs(page);
  639 |     return { id, mode: unmasked ? "UNMASKED" : "MASKED" };
  640 |   } finally {
  641 |     page.off("request", requestListener);
  642 |     page.off("response", listener);
  643 |   }
  644 | }
  645 |
  646 | async function findExcelDialog(page: Page, timeoutMs = 1_000): Promise<Locator | undefined> {
  647 |   const checkbox = await firstVisible(page.getByLabel(/마스킹 해제 후 다운로드/), timeoutMs);
  648 |   if (!checkbox) return undefined;
  649 |   const dialog = checkbox.locator("xpath=ancestor::*[@role='dialog'][1]");
  650 |   return (await dialog.count()) > 0 ? dialog : page.locator("body");
  651 | }
  652 |
  653 | async function findPinInput(page: Page, timeoutMs: number): Promise<Locator | undefined> {
  654 |   const deadline = Date.now() + timeoutMs;
  655 |   do {
  656 |     const dialogs = page.locator("[role='dialog']:visible");
  657 |     const count = await dialogs.count().catch(() => 0);
  658 |     for (let index = 0; index < count; index += 1) {
  659 |       const dialog = dialogs.nth(index);
  660 |       const text = normalize(await dialog.innerText().catch(() => ""));
  661 |       if (!/PIN\s*인증|PIN\s*번호/.test(text)) continue;
  662 |       const input = await firstVisible(dialog.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"));
  663 |       if (input) return input;
  664 |     }
  665 |     const explicit = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin']"));
  666 |     if (explicit) return explicit;
  667 |     if (Date.now() < deadline) await page.waitForTimeout(100);
  668 |   } while (Date.now() < deadline);
  669 |   return undefined;
  670 | }
  671 |
```