import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const artifactDir = process.env.REF_AUTO_LOGIN_OTP_ARTIFACT_DIR; if (!artifactDir) { throw new Error('REF_AUTO_LOGIN_OTP_ARTIFACT_DIR is required'); } const playwrightRequire = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); let chromium; try { ({chromium} = playwrightRequire('playwright')); } catch { ({chromium} = playwrightRequire('@playwright/test')); } const bundleDir = path.join(projectRoot, 'dist_js/gateway'); const loginToken = [1, [321, 'browser-test-token'], Date.now()]; let autoLoginCount = 0; let manualLoginBody = null; let otpBody = ''; let otpAccepted = false; const loginHTML = `
로그인 완료
' : loginHTML); return; } if (requestURL.pathname.startsWith('/dist_js/gateway/')) { const fileName = path.basename(requestURL.pathname); const filePath = path.join(bundleDir, fileName); const contentType = fileName.endsWith('.css') ? 'text/css' : 'text/javascript'; response.writeHead(200, {'content-type': `${contentType}; charset=utf-8`}); response.end(fs.readFileSync(filePath)); return; } if (requestURL.pathname === '/api.php') { const apiPath = requestURL.searchParams.get('path'); const rawBody = await readBody(request); if (apiPath === 'Login/ReqNonce') { json(response, {result: true, loginNonce: `nonce-${autoLoginCount + 1}`}); return; } if (apiPath === 'Login/LoginByToken') { autoLoginCount += 1; json(response, { result: false, silent: false, reqOTP: true, reason: '인증 코드를 입력해주세요', }); return; } if (apiPath === 'Login/LoginByID') { manualLoginBody = JSON.parse(rawBody); json(response, {result: false, reqOTP: false, reason: '수동 로그인 요청 확인'}); return; } } if (requestURL.pathname === '/oauth_kakao/j_check_OTP.php') { otpBody = await readBody(request); otpAccepted = true; json(response, {result: true, reset: false, validUntil: '2026-08-18 00:00:00'}); return; } response.writeHead(404); response.end('not found'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); assert(address && typeof address !== 'string'); const baseURL = `http://127.0.0.1:${address.port}/`; const browser = await chromium.launch({headless: true}); try { const context = await browser.newContext({ viewport: {width: 1280, height: 960}, deviceScaleFactor: 1, locale: 'ko-KR', }); await context.addInitScript(token => { if (!localStorage.getItem('sammo_login_token')) { localStorage.setItem('sammo_login_token', JSON.stringify(token)); } }, loginToken); const page = await context.newPage(); const openAndAcceptOtpPrompt = async navigation => { const dialogPromise = page.waitForEvent('dialog'); const navigationPromise = navigation(); const dialog = await dialogPromise; assert.equal(dialog.message(), '인증 코드 입력이 필요합니다.'); await dialog.accept(); await navigationPromise; await page.locator('#modalOTP.show').waitFor({state: 'visible'}); await page.waitForFunction(() => document.activeElement?.id === 'otp_code'); }; await openAndAcceptOtpPrompt(() => page.goto(baseURL, {waitUntil: 'domcontentloaded'})); assert.equal(autoLoginCount, 1); assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); fs.mkdirSync(artifactDir, {recursive: true}); const screenshotPath = path.join(artifactDir, 'auto-login-otp-modal.png'); await page.screenshot({path: screenshotPath, fullPage: true}); const modalGeometry = await page.locator('#modalOTP .modal-dialog').evaluate(element => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, display: style.display, opacity: style.opacity, }; }); await page.getByRole('button', {name: '취소'}).click(); await page.locator('#modalOTP').waitFor({state: 'hidden'}); assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); assert.equal(autoLoginCount, 2); await page.getByRole('button', {name: '취소'}).click(); await page.locator('#modalOTP').waitFor({state: 'hidden'}); await page.waitForTimeout(250); await page.locator('#username').fill('manual-user'); await page.locator('#password').fill('manual-password'); await page.waitForTimeout(100); const manualDialogPromise = page.waitForEvent('dialog'); await page.locator('#main_form').evaluate(form => form.requestSubmit()); const manualDialog = await manualDialogPromise; assert.equal(manualDialog.message(), '수동 로그인 요청 확인'); await manualDialog.accept(); assert.equal(manualLoginBody?.username, 'manual-user'); assert.match(manualLoginBody?.password ?? '', /^[0-9a-f]{128}$/); await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); assert.equal(autoLoginCount, 3); await page.locator('#otp_code').fill('1234'); const successDialogPromise = page.waitForEvent('dialog'); await page.getByRole('button', {name: '제출'}).click(); const successDialog = await successDialogPromise; assert.equal(successDialog.message(), '로그인되었습니다. 2026-08-18 00:00:00까지 유효합니다.'); await successDialog.accept(); await page.locator('#logged-in').waitFor({state: 'visible'}); assert.match(otpBody, /name="otp"/); assert.match(otpBody, /\r\n1234\r\n/); const resultPath = path.join(artifactDir, 'auto-login-otp-result.json'); fs.writeFileSync(resultPath, `${JSON.stringify({ url: baseURL, viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, autoLoginCount, preservedTokenAfterCancel: true, reopenedAfterReload: true, manualLoginSubmitted: true, otpSuccessNavigated: true, modalGeometry, screenshotPath, }, null, 2)}\n`, {mode: 0o600}); console.log(`automatic-login OTP Chromium flow verified: ${resultPath}`); } finally { await browser.close(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); }