# 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: 4104/dev/duplicate-login-second-factor.spec.ts >> [4104][dev] SMS/Email 2차 인증 중복 로그인 강제로그인 모달 검증 >> jjm_email: Email 2차 인증으로 일반/중복 로그인을 완료하고 강제로그인 후 현재 세션 활성화와 기존 세션 종료를 확인
- Location: src/qa/scenarios/duplicateLoginSecondFactor.ts:45:7

# Error details

```
Error: 이메일 2차 인증 중복 로그인 시 강제로그인 확인 모달이 단독으로 표시되어야 합니다.

expect(received).toBe(expected) // Object.is equality

Expected: true
Received: false
```

# Test source

```ts
  101 |   const firstPage = await firstContext.newPage();
  102 |   const secondPage = await secondContext.newPage();
  103 |
  104 |   try {
  105 |     await loginFully(firstPage, runtime, account, "primary");
  106 |     await submitCredentials(secondPage, runtime, account);
  107 |     await completeSecondFactor(secondPage, account, "duplicate");
  108 |     await secondPage.waitForTimeout(800);
  109 |     return { firstContext, secondContext, firstPage, secondPage };
  110 |   } catch (error) {
  111 |     await Promise.allSettled([firstContext.close(), secondContext.close()]);
  112 |     throw error;
  113 |   }
  114 | }
  115 |
  116 | async function loginFully(page: Page, runtime: RuntimeQaEnv, account: Account, stage: LoginStage): Promise<void> {
  117 |   await submitCredentials(page, runtime, account);
  118 |   await completeSecondFactor(page, account, stage);
  119 |   await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 20_000 });
  120 | }
  121 |
  122 | async function submitCredentials(page: Page, runtime: RuntimeQaEnv, account: Account): Promise<void> {
  123 |   const password = requiredEnv("QA_4104_ACCOUNT_PASSWORD");
  124 |   if (!runtime.loginUrl) {
  125 |     blocked("개발계 어드민 loginUrl이 필요합니다.");
  126 |   }
  127 |   await page.goto(runtime.loginUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
  128 |   await page.locator(runtime.usernameSelector).first().fill(account.username);
  129 |   await page.locator(runtime.passwordSelector).first().fill(password);
  130 |   await clickFirstVisible(page.locator(runtime.submitSelector));
  131 | }
  132 |
  133 | async function completeSecondFactor(page: Page, account: Account, stage: LoginStage): Promise<void> {
  134 |   await expectSecondFactorPrompt(page, account);
  135 |   if (account.method !== "pin") {
  136 |     if (account.method === "sms") {
  137 |       await page.getByPlaceholder("회원정보에 등록된 전화번호 입력").fill(requiredEnv("QA_4104_SMS_PHONE"));
  138 |     }
  139 |     const send = page.getByRole("button", { name: /인증번호\s*(전송|재전송)/ }).last();
  140 |     await expect(send).toBeVisible({ timeout: 10_000 });
  141 |     await send.click();
  142 |     const sentNotice = page.getByText("인증번호를 보냈습니다", { exact: true });
  143 |     if (await sentNotice.waitFor({ state: "visible", timeout: 5_000 }).then(() => true).catch(() => false)) {
  144 |       await clickExactButton(page, "OK");
  145 |       await expect(sentNotice).toBeHidden({ timeout: 10_000 });
  146 |     }
  147 |   }
  148 |   const code = await secondFactorCode(account, stage);
  149 |   await secondFactorInput(page, account).fill(code);
  150 |   await clickExactButton(page, "확인");
  151 |   await page.waitForTimeout(800);
  152 | }
  153 |
  154 | async function expectSecondFactorPrompt(page: Page, account: Account): Promise<void> {
  155 |   const input = secondFactorInput(page, account);
  156 |   await expect(input, `${account.label} 인증 입력 모달이 표시되어야 합니다.`).toBeVisible({ timeout: 30_000 });
  157 | }
  158 |
  159 | function secondFactorInput(page: Page, account: Account) {
  160 |   const placeholder = account.method === "pin" ? "PIN 번호 입력" : account.method === "email" ? "이메일 인증 번호 입력" : "SMS 인증 번호 입력";
  161 |   return page.getByPlaceholder(placeholder);
  162 | }
  163 |
  164 | async function secondFactorCode(account: Account, stage: LoginStage): Promise<string> {
  165 |   if (account.method === "pin") {
  166 |     return requiredEnv("QA_4104_PIN");
  167 |   }
  168 |   const key = `QA_4104_${account.method.toUpperCase()}_OTP_${stage.toUpperCase()}`;
  169 |   const value = process.env[key];
  170 |   if (value) {
  171 |     return validateOtp(value, key);
  172 |   }
  173 |   const directory = process.env.QA_4104_OTP_DIR;
  174 |   if (!directory) {
  175 |     blocked(`${account.label} 완료 검증에는 ${key} 또는 QA_4104_OTP_DIR이 필요합니다.`);
  176 |   }
  177 |   const otpPath = path.join(directory, `${account.method}-${stage}.txt`);
  178 |   console.log(`[qa:4104] ${account.method}-${stage} 인증번호 대기: ${otpPath}`);
  179 |   const waitMs = positiveNumber(process.env.QA_4104_OTP_WAIT_MS, 240_000);
  180 |   const deadline = Date.now() + waitMs;
  181 |   while (Date.now() < deadline) {
  182 |     if (fs.existsSync(otpPath)) {
  183 |       const otp = fs.readFileSync(otpPath, "utf8").trim();
  184 |       fs.rmSync(otpPath, { force: true });
  185 |       return validateOtp(otp, otpPath);
  186 |     }
  187 |     await new Promise((resolve) => setTimeout(resolve, 500));
  188 |   }
  189 |   blocked(`${account.label} ${stage} 인증번호를 ${Math.round(waitMs / 1000)}초 안에 받지 못했습니다.`);
  190 | }
  191 |
  192 | function validateOtp(value: string, source: string): string {
  193 |   if (!/^\d{6}$/.test(value)) {
  194 |     blocked(`${source} 인증번호는 6자리 숫자여야 합니다.`);
  195 |   }
  196 |   return value;
  197 | }
  198 |
  199 | async function expectForceModalOnly(page: Page, account: Account): Promise<void> {
  200 |   const surface = await inspectSurface(page, account);
> 201 |   expect(surface.forceVisible, `${account.label} 중복 로그인 시 강제로그인 확인 모달이 단독으로 표시되어야 합니다.`).toBe(true);
      |                                                                                          ^ Error: 이메일 2차 인증 중복 로그인 시 강제로그인 확인 모달이 단독으로 표시되어야 합니다.
  202 |   expect(surface.factorVisible, `${account.label} 강제로그인 확인 모달과 2차 인증 모달이 겹치면 안 됩니다.`).toBe(false);
  203 | }
  204 |
  205 | async function clickForceButton(page: Page, name: RegExp): Promise<void> {
  206 |   const modal = forceModal(page);
  207 |   const button = modal.getByRole("button", { name }).last();
  208 |   await expect(button).toBeVisible({ timeout: 5_000 });
  209 |   await button.click();
  210 | }
  211 |
  212 | async function forceModalText(page: Page): Promise<string> {
  213 |   const modal = forceModal(page);
  214 |   await expect(modal).toBeVisible({ timeout: 10_000 });
  215 |   return normalize(await modal.innerText());
  216 | }
  217 |
  218 | function forceModal(page: Page) {
  219 |   return page.locator("[role='dialog'], .fixed.inset-0").filter({ hasText: FORCE_MODAL }).last();
  220 | }
  221 |
  222 | interface LoginSurface {
  223 |   forceVisible: boolean;
  224 |   factorVisible: boolean;
  225 |   path: string;
  226 |   body: string;
  227 | }
  228 |
  229 | async function inspectSurface(page: Page, account: Account): Promise<LoginSurface> {
  230 |   const body = normalize(await page.locator("body").innerText().catch(() => ""));
  231 |   const factorVisible = await secondFactorInput(page, account).isVisible().catch(() => false);
  232 |   return {
  233 |     forceVisible: await forceModal(page).isVisible().catch(() => false),
  234 |     factorVisible,
  235 |     path: new URL(page.url()).pathname,
  236 |     body: body.slice(-700)
  237 |   };
  238 | }
  239 |
  240 | function describeLoginSurface(surface: LoginSurface): string[] {
  241 |   return [
  242 |     `강제로그인 확인 모달: ${surface.forceVisible ? "노출" : "미노출"}`,
  243 |     `2차 인증 입력 모달: ${surface.factorVisible ? "노출" : "미노출"}`,
  244 |     `현재 경로: ${surface.path}`,
  245 |     `화면 일부: ${surface.body || "-"}`
  246 |   ];
  247 | }
  248 |
  249 | async function clickExactButton(page: Page, name: string): Promise<void> {
  250 |   const buttons = page.getByRole("button", { name, exact: true });
  251 |   const count = await buttons.count();
  252 |   for (let index = count - 1; index >= 0; index -= 1) {
  253 |     const button = buttons.nth(index);
  254 |     if (await button.isVisible().catch(() => false)) {
  255 |       await button.click();
  256 |       return;
  257 |     }
  258 |   }
  259 |   await buttons.last().click();
  260 | }
  261 |
  262 | async function clickFirstVisible(locator: ReturnType<Page["locator"]>): Promise<void> {
  263 |   const count = await locator.count();
  264 |   for (let index = 0; index < count; index += 1) {
  265 |     const candidate = locator.nth(index);
  266 |     if (await candidate.isVisible().catch(() => false)) {
  267 |       await candidate.click();
  268 |       return;
  269 |     }
  270 |   }
  271 |   await locator.first().click();
  272 | }
  273 |
  274 | async function attachEvidence(testInfo: TestInfo, page: Page, slug: string, lines: string[]): Promise<void> {
  275 |   await testInfo.attach(`${slug}.md`, { body: `${lines.join("\n")}\n`, contentType: "text/markdown" });
  276 |   const screenshot = testInfo.outputPath(`${slug}.png`);
  277 |   await page.screenshot({ path: screenshot, fullPage: true });
  278 |   await testInfo.attach(`${slug}.png`, { path: screenshot, contentType: "image/png" });
  279 | }
  280 |
  281 | async function closeDuplicateState(state: DuplicateState): Promise<void> {
  282 |   await state.firstContext.close();
  283 |   await state.secondContext.close();
  284 | }
  285 |
  286 | function requiredEnv(key: string): string {
  287 |   const value = process.env[key];
  288 |   if (!value) {
  289 |     blocked(`${key} 런타임 환경변수가 필요합니다. 실제 비밀번호와 인증번호는 저장소 및 QA 산출물에 기록하지 않습니다.`);
  290 |   }
  291 |   return value;
  292 | }
  293 |
  294 | function blocked(message: string): never {
  295 |   throw new Error(`BLOCKED: ${message}`);
  296 | }
  297 |
  298 | function normalize(value: string): string {
  299 |   return value.replace(/\s+/g, " ").trim();
  300 | }
  301 |
```