fix(e2e,a11y): 확장 E2E 로케일 고정 + 관리자 탭·스위치 접근성 보완

확장 E2E config 의 로케일 미고정을 코어 포함 7개에서 해소했다. 미지정 시 첫 페이지
로드의 Accept-Language 가 en-US 로 나가고 엔진이 그 값을 localStorage 에 굳혀,
인증 후에도 세션 전체가 영어로 렌더돼 한국어 문구를 단언하는 spec 이 전멸한다.
같은 함정을 정적으로 막는 룰을 함께 등록했다.

관리자 공용 탭·스위치는 ARIA 와 키보드 처리가 없어 마우스 없이는 조작할 수 없었고
보조기기에 목록·선택 상태가 전달되지 않았다. WAI-ARIA 규약대로 보완하고, role 변경에
맞춰 낡은 셀렉터를 갱신했다(admin_basic E2E 회귀 1건 포함).

nhnkcp 설정 화면은 저장 후 정규화 결과가 입력칸에 반영되지 않았다. 계측 결과
refetch 와 initLocal 은 정상 동작하며 _local 도 갱신되지만 사용자가 편집한 입력칸만
값을 유지한다. 저장 응답으로 폼을 명시 재바인딩하고 그 기전을 트러블슈팅에 남겼다.

safelist 생성기가 빌드마다 main.css 에 빈 줄을 누적해 매 빌드가 소스를 변경된 것으로
만들던 문제도 함께 고쳤다.
This commit is contained in:
HeuJung
2026-07-31 00:43:56 +09:00
parent b127afcea7
commit 6b3903553a
36 changed files with 934 additions and 489 deletions
+17 -1
View File
@@ -6,6 +6,7 @@
- 도구: Playwright 1.49+ (TypeScript, Vitest 와 동일 스택)
- 인증: PlaywrightIssueToken artisan 커맨드가 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS=1 + APP_DEBUG 3중 가드)
- Base URL: PLAYWRIGHT_BASE_URL 환경변수 우선, .env APP_URL 차순위 (하드코딩 회피)
- 로케일: 모든 config 에 use.locale = 'ko-KR' 고정 (미지정 시 Accept-Language 가 en-US 로 나가 화면이 영어로 렌더 → 한국어 단언 전멸)
- 코어/확장 분리: 코어 = tests/Playwright/, 확장 = {확장 디렉토리}/tests/Playwright/
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
@@ -87,13 +88,28 @@ npm run test:e2e:ui # UI 디버깅 모드
base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다).
### 로케일 고정 (`locale: 'ko-KR'`)
코어와 확장의 모든 config 은 `use.locale` 을 `'ko-KR'` 로 고정한다. 생략하면 한국어 문구를 단언하는
spec 이 전부 실패한다.
엔진의 로케일 우선순위는 `localStorage.g7_locale` → 서버가 내려준 값 → `'ko'` 이고
([TemplateApp.ts](../../resources/js/core/TemplateApp.ts) 생성자), 서버값은 `SetLocale` 미들웨어가
**미로그인 요청에서 `Accept-Language` 헤더로** 결정한다. Playwright 의 `locale` 옵션이 그 헤더를
만들므로, 지정하지 않으면 첫 페이지 로드가 `en-US` 로 나가 화면이 영어로 렌더되고 엔진이 그 값을
`localStorage` 에 저장한다. 이후 인증해도 저장된 값이 최우선이라 세션 전체가 영어로 고정된다.
`getByText('전체회원수')` 처럼 한국어만 단언하는 곳은 물론이고, `getByRole('button', { name: /저장/ })`
같은 접근 가능한 이름 조회도 함께 깨진다.
### 실행 시 유의 (경험칙)
| 항목 | 내용 |
| --- | --- |
| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 |
| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 |
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 설정 저장을 수반하는 spec 은 `--workers=1` 로 확인한다 |
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 |
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
@@ -65,6 +65,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -76,6 +76,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -1656,7 +1656,8 @@
"name": "Span",
"if": "{{row.version !== 1 && row.changes_summary?.changed_fields?.length > 0}}",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-sm text-gray-600 dark:text-gray-400",
"data-testid": "page-version-changed-fields"
},
"text": "{{row.changes_summary?.changed_fields?.map(f => $t('sirsoft-page.admin.page.detail.versions.field_labels.' + f)).join(', ') ?? '-'}}"
},
@@ -423,7 +423,8 @@
"type": "basic",
"name": "Span",
"props": {
"className": "form-hint block"
"className": "form-hint block",
"data-testid": "page-slug-hint"
},
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug || 'example'}}"
},
@@ -956,7 +957,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "btn btn-secondary"
"className": "btn btn-secondary",
"data-testid": "page-form-cancel"
},
"actions": [
{
@@ -76,6 +76,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -1,23 +1,20 @@
/**
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로
* (placeholder — data-testid 보강 후 활성화).
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로.
*
* 결함: 취소 버튼이 navigateBack(history.back)이라 직전 위치(다른 관리 메뉴)로 튀거나 URL 직접
* 진입 시 아무 데도 못 갔다. navigate + route.id 분기(수정=상세 /admin/pages/{id},
* 등록=목록 /admin/pages)로 교체했다.
*
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
* navigate 로 목록/상세를 명시함")가 핸들러와 path 표현식을 구조적으로 잠그고, 본 spec 이
* 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 브라우저에서 잠근다.
*
* @scenario form_mode=create, form_mode=edit
* @effects cancel_in_edit_mode_navigates_to_detail,
* cancel_in_create_mode_navigates_to_list
*
* e2e:allow 취소 버튼이 navigateBack(history.back)이던 회귀 — 직전 위치(다른 관리 메뉴)로
* 튀거나 URL 직접 진입 시 멈추던 문제를, navigate + route.id 분기(수정=상세 /admin/pages/{id},
* 등록=목록 /admin/pages)로 교체했다.
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
* navigate 로 목록/상세를 명시함")가 핸들러가 navigateBack 이 아니라 navigate 이고 path 표현식이
* route.id 분기를 가짐을 구조적으로 회귀 차단한다. 본 placeholder spec(test.describe.skip)은
* 취소 버튼에 data-testid 보강 후 활성화되어 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
* 1. 취소 버튼(footer_cancel_button)에 data-testid="page-form-cancel"
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
* 3. test.describe.skip → test.describe 변경
* 남은 개선: EDIT_URL 이 /admin/pages/1 을 하드코딩해 1번 페이지가 존재하는 환경에만 의존한다.
* seededPage fixture(playwright:seed-page)가 돌려주는 id 로 교체하면 환경 독립이 된다.
*
* 매트릭스(시나리오 매니페스트 form_mode axis 와 1:1):
* - edit 모드 직접 진입 → 취소 → /admin/pages/{id} (상세)
@@ -28,7 +25,7 @@ import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const EDIT_URL = '/admin/pages/1/edit';
const CREATE_URL = '/admin/pages/create';
test.describe.skip('페이지 폼 취소 이동 경로 (placeholder — data-testid 보강 후 활성화)', () => {
test.describe('페이지 폼 취소 이동 경로', () => {
test('수정 폼에 직접 진입 후 취소하면 해당 페이지 상세로 이동한다', async ({
page,
pageManageToken,
@@ -13,10 +13,15 @@
* $t('sirsoft-page.admin.page.detail.versions.field_labels.<field>') 로 매핑함을
* 구조적으로 회귀 차단한다. 브라우저 실제 로케일 렌더만 본 spec 이 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. 버전 이력 행의 변경 항목 셀에 data-testid="page-version-changed-fields"
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현 (버전 이력 있는 페이지)
* 3. test.describe.skip → test.describe 변경
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
* 1. (완료) 버전 이력 행의 변경 항목 셀에 data-testid="page-version-changed-fields"
* 2. playwright:seed-page 가 **변경 이력이 있는 버전**까지 생성하도록 확장.
* 현재 시드는 페이지 + 첨부만 만들고 버전 행을 만들지 않는다. 레이아웃의 변경 항목 셀은
* `row.version !== 1 && row.changes_summary?.changed_fields?.length > 0` 조건이라
* 버전 1뿐인 페이지에서는 아예 렌더되지 않는다 (실측: element not found).
* 3. DETAIL_URL 하드코딩(/admin/pages/1) 제거 — seededPage fixture 가 돌려주는 id 사용.
* 지금은 우연히 존재하는 1번 페이지에 의존해 환경마다 결과가 달라진다.
* 4. test.describe.skip → test.describe 변경
*
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 page_change=update 와 1:1):
* - update 후 상세 진입: 변경 항목이 필드 키 원문이 아닌 다국어 라벨로 표시
@@ -25,7 +30,7 @@ import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const DETAIL_URL = '/admin/pages/1';
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — data-testid 보강 후 활성화)', () => {
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — 버전 이력 시드 후 활성화)', () => {
test('버전 이력의 변경 항목이 필드 키 원문 대신 다국어 라벨로 표시된다', async ({
page,
pageReadToken,
+6
View File
@@ -72,6 +72,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
#### 관리자 설정
- 운영 모드로 전환할 때 사이트코드와 암호화 키가 비어 있으면 저장이 차단되고, 어떤 항목이 필요한지 해당 입력칸에 안내가 표시됩니다.
- 운영 사이트코드는 앞의 SM 을 빼고 입력하면 저장 시 자동으로 붙습니다. SM 을 포함해 입력해도 중복으로 붙지 않습니다.
- 운영 사이트코드는 앞의 SM 을 빼고 입력합니다. SM 을 포함해 입력하거나 붙여넣어도 저장할 때 앞의 SM 을 떼어 보관하므로 입력칸 옆의 SM 표시와 겹쳐 두 번 붙은 것처럼 보이지 않으며, 실제 인증 요청에는 항상 SM 이 한 번만 붙은 사이트코드가 사용됩니다. 대소문자를 섞어 입력한 경우에도 동일하게 처리됩니다.
- 암호화 키는 저장 시 암호화하여 보관하며 화면에 다시 노출되지 않습니다.
- KCP 가맹점 관리자에서 발급받은 사이트 식별코드(웹사이트 ID)를 입력하는 항목을 제공합니다. 동일인 식별값(DI) 수신에 사용됩니다.
@@ -8,7 +8,9 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
},
"devDependencies": {
"jsdom": "^27.4.0",
@@ -92,6 +92,73 @@ describe('NHN KCP 환경설정 레이아웃 렌더링', () => {
utils.cleanup?.();
});
// @scenario mode=live,live_credentials=complete
// @effects sm_prefix_is_added_once
it('사이트코드 입력칸은 _local.form 값을 그대로 따라간다', async () => {
// 서버는 저장 과정에서 SM 프리픽스를 떼어 보관한다. 화면이 그 결과를 반영하지 못하면
// 입력칸 왼쪽 SM 배지와 겹쳐 `SMSMA1B2C` 로 보인다.
const { container, utils } = await renderSettings({
is_test_mode: false,
duplicate_field: 'di',
live_site_cd: 'SMA1B2C',
});
const siteCd = container.querySelector('input[name="live_site_cd"]') as HTMLInputElement;
expect(siteCd).toBeTruthy();
expect(siteCd.value).toBe('SMA1B2C');
utils.cleanup?.();
// 저장 응답이 정규화된 값을 되돌린 뒤의 폼 상태 — 같은 입력칸이 그 값을 보여야 한다.
const normalized = await renderSettings({
is_test_mode: false,
duplicate_field: 'di',
live_site_cd: 'A1B2C',
});
const after = normalized.container.querySelector('input[name="live_site_cd"]') as HTMLInputElement;
expect(after.value).toBe('A1B2C');
normalized.utils.cleanup?.();
});
// @scenario mode=live,live_credentials=complete
// @effects sm_prefix_is_added_once
it('저장 성공 시 응답 데이터를 폼에 되돌리는 액션이 있다', async () => {
// 앞 테스트가 "폼이 갱신되면 화면도 갱신된다" 를 잠그고, 이 테스트가 "저장 성공이
// 실제로 폼을 갱신한다" 를 잠근다. 둘 중 하나만 있으면 회귀를 놓친다.
const json = JSON.stringify(settingsLayout);
const saveCall = JSON.parse(json);
const findApiCall = (node: unknown): any => {
if (Array.isArray(node)) {
for (const child of node) {
const hit = findApiCall(child);
if (hit) return hit;
}
return null;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, any>;
if (obj.handler === 'apiCall' && obj.params?.method === 'PUT') return obj;
for (const value of Object.values(obj)) {
const hit = findApiCall(value);
if (hit) return hit;
}
}
return null;
};
const action = findApiCall(saveCall);
expect(action).toBeTruthy();
const rebind = (action.onSuccess ?? []).find(
(a: any) => a.handler === 'setState' && a.params?.form === '{{response.data}}',
);
expect(rebind).toBeTruthy();
expect(rebind.params.target).toBe('local');
});
// @scenario mode=live,live_credentials=complete
// @effects live_mode_warning_shown_in_live_mode
it('운영 모드로 전환하면 경고 문구가 나타난다', async () => {
@@ -675,6 +675,11 @@
"body": "{{_local.form}}"
},
"onSuccess": [
{
"comment": "서버가 저장 과정에서 값을 정규화하므로(사이트코드 SM 프리픽스 분리, 암호화 키 마스킹) 응답을 폼에 되돌려 화면과 저장 상태를 일치시킨다. 뒤따르는 refetchDataSource 만으로는 부족하다 — 실측 결과 refetch 는 발생하고 initLocal 도 적용되어 _local.form 은 정규화된 값을 갖지만, 사용자가 직접 입력한 입력칸은 그 값을 다시 받아 쓰지 않아 화면에만 입력값이 남는다. 이 setState 로 폼을 응답으로 명시 재바인딩해야 입력칸이 갱신된다.",
"handler": "setState",
"params": { "target": "local", "form": "{{response.data}}" }
},
{
"handler": "setState",
"params": { "target": "local", "isSaving": false, "hasChanges": false }
@@ -675,6 +675,11 @@ class KcpIdentityProvider implements IdentityVerificationInterface
* 운영자가 설정 UI 에서 프리픽스 분리형 input 으로 입력하므로 일반적으로 프리픽스 미포함
* 값이 들어오지만, 직접 프리픽스를 포함해 입력한 경우에도 중복 부착하지 않는다.
*
* 프리픽스 판정은 대소문자를 구분하지 않고, 결과는 KCP 정책값(대문자)으로 정규화한다.
* 사이트코드는 대문자로 발급되지만 프리픽스를 손으로 옮겨 적는 과정에서 소문자가 섞일 수
* 있는데, 대소문자를 구분해 판정하면 `SMsmA1B2C` 가 만들어져 모든 인증 요청이 잘못된
* 사이트코드로 나간다.
*
* @param string $value 운영자 입력 값
* @return string 프리픽스가 보장된 라이브 사이트코드
*/
@@ -685,9 +690,13 @@ class KcpIdentityProvider implements IdentityVerificationInterface
return '';
}
return str_starts_with($value, self::LIVE_SITE_CD_PREFIX)
? $value
: self::LIVE_SITE_CD_PREFIX.$value;
$prefixLength = strlen(self::LIVE_SITE_CD_PREFIX);
if (strncasecmp($value, self::LIVE_SITE_CD_PREFIX, $prefixLength) === 0) {
return self::LIVE_SITE_CD_PREFIX.substr($value, $prefixLength);
}
return self::LIVE_SITE_CD_PREFIX.$value;
}
/**
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Lang;
use Plugins\Sirsoft\VerificationNhnkcp\Identity\KcpIdentityProvider;
/**
* KCP 본인확인 설정 저장 시 라이브 모드 조건부 검증을 주입하는 filter 훅 listener.
@@ -37,6 +38,12 @@ class ValidateKcpSettingsListener implements HookListenerInterface
'type' => 'filter',
'sync' => true,
],
'core.plugin_settings.filter_save_data' => [
'method' => 'normalizeLiveSiteCd',
'priority' => 10,
'type' => 'filter',
'sync' => true,
],
];
}
@@ -99,6 +106,44 @@ class ValidateKcpSettingsListener implements HookListenerInterface
return $rules;
}
/**
* 저장 직전 운영 사이트코드에서 SM 프리픽스를 떼어 정규화한다.
*
* 설정 화면은 입력칸 왼쪽에 `SM` 배지를 따로 두고 프리픽스를 제외한 값만 받는다. 프리픽스가
* 포함된 채로 보관되면 화면이 `SM` + `SMA1B2C` 로 그려져 운영자에게는 프리픽스가 두 번 붙은
* 것처럼 보인다. 보관 형태를 프리픽스 미포함으로 통일해 화면 표시와 일치시킨다.
*
* 런타임 부착은 KcpIdentityProvider::buildLiveSiteCd() 가 계속 담당하므로 최종 사이트코드는
* 정규화 전후가 동일하다.
*
* @param array<string, mixed> $settings 저장 요청 설정 배열
* @param string $identifier 저장 대상 플러그인 식별자
* @return array<string, mixed> 사이트코드가 정규화된 설정 배열
*/
public function normalizeLiveSiteCd(array $settings, string $identifier): array
{
if ($identifier !== self::IDENTIFIER || ! isset($settings['live_site_cd'])) {
return $settings;
}
if (! is_string($settings['live_site_cd'])) {
return $settings;
}
$value = trim($settings['live_site_cd']);
$prefix = KcpIdentityProvider::LIVE_SITE_CD_PREFIX;
// 프리픽스 판정은 buildLiveSiteCd() 와 같은 기준(대소문자 무시)을 쓴다 — 두 곳이 어긋나면
// 한쪽은 떼고 다른 쪽은 붙이지 않아 사이트코드가 조용히 달라진다.
if (strncasecmp($value, $prefix, strlen($prefix)) === 0) {
$value = substr($value, strlen($prefix));
}
$settings['live_site_cd'] = $value;
return $settings;
}
/**
* 라이브 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다.
*
@@ -201,6 +201,58 @@ class KcpLiveModeSettingsValidationTest extends PluginTestCase
);
}
/**
* 사이트코드에 SM 프리픽스를 포함해 입력하면 저장 시 프리픽스를 떼고 보관해야 한다.
*
* 설정 화면은 입력칸 왼쪽에 `SM` 배지를 따로 두고 "SM 을 제외한 값만" 받는다. 프리픽스가
* 포함된 채로 저장되면 화면이 `SM` + `SMZ9Y8X` 로 그려져 운영자에게는 프리픽스가 두 번
* 붙은 것처럼 보인다. 저장값을 프리픽스 미포함으로 정규화해 화면 표시와 보관 형태를
* 일치시킨다 (런타임 부착은 buildLiveSiteCd 가 계속 담당).
*
* @scenario mode=live,live_credentials=complete
*
* @effects sm_prefix_is_added_once
*/
public function test_site_cd_is_stored_without_sm_prefix(): void
{
foreach (['SMZ9Y8X' => 'Z9Y8X', 'smZ9Y8X' => 'Z9Y8X', 'Z9Y8X' => 'Z9Y8X'] as $input => $expected) {
$this->putSettings([
'is_test_mode' => false,
'live_site_cd' => $input,
'live_enc_key' => 'super-secret-live-key',
])->assertStatus(200);
$this->assertSame(
$expected,
app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER, 'live_site_cd'),
"입력 [{$input}] 은 프리픽스 없이 보관되어야 한다",
);
}
}
/**
* 정규화 후에도 런타임 사이트코드는 프리픽스가 붙은 최종값이어야 한다.
*
* @scenario mode=live,live_credentials=complete
*
* @effects sm_prefix_is_added_once
*/
public function test_normalized_site_cd_still_resolves_with_prefix(): void
{
$this->putSettings([
'is_test_mode' => false,
'live_site_cd' => 'SMZ9Y8X',
'live_enc_key' => 'super-secret-live-key',
])->assertStatus(200);
$provider = app()->makeWith(
\Plugins\Sirsoft\VerificationNhnkcp\Identity\KcpIdentityProvider::class,
['config' => app(PluginSettingsService::class)->get(self::PLUGIN_IDENTIFIER)],
);
$this->assertSame('SMZ9Y8X', $provider->resolveSiteCd());
}
/**
* core.plugins.update 권한을 가진 관리자 생성.
*/
@@ -85,6 +85,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -15,11 +15,58 @@ import { test, expect, authenticatePage } from '../../fixtures/nhnkcp-auth';
const SETTINGS_PATH = '/admin/plugins/sirsoft-verification_nhnkcp/settings';
/**
* 토글 스위치를 조작한다.
*
* 실제 checkbox 는 sr-only(1×1, tabindex=-1) 로 숨겨져 있고 사용자가 누르는 대상은
* 옆의 트랙 요소다. checkbox 를 직접 클릭하면 상태가 바뀌지 않는다.
*
* @param page Playwright page
* @param name checkbox 의 name 속성
*/
async function toggleSwitch(page: import('@playwright/test').Page, name: string): Promise<void> {
const track = page.locator(`.toggle-switch-wrapper:has(input[name="${name}"]) .toggle-switch-track`).first();
await expect(track).toBeVisible({ timeout: 20_000 });
await track.click();
}
test.describe('NHN KCP 본인확인 환경설정', () => {
// 이 파일의 모든 테스트가 같은 플러그인 설정 파일을 쓴다. config 는 fullyParallel 이라
// 파일 안의 테스트도 병렬로 갈릴 수 있어, 서로의 저장 결과를 덮어써 원복이 어긋난다.
// 실행 옵션(--workers=1)에 맡기지 않고 spec 자체가 직렬을 보장한다.
test.describe.configure({ mode: 'serial' });
/**
* 화면 진입 시 서버가 돌려준 설정 스냅샷.
*
* 원복 기준값을 DOM 에서 읽으면 폼 바인딩이 끝나기 전에 빈 문자열을 원본으로 잡을 수 있어
* 응답 본문에서 직접 가져온다.
*/
let settingsSnapshot: Record<string, unknown> = {};
test.beforeEach(async ({ page, pluginSettingsToken }) => {
await authenticatePage(page, pluginSettingsToken);
// 설정 응답이 도착해야 폼(_local.form)이 채워진다. domcontentloaded 만 기다리면
// 응답 전에 입력을 시작해 변경분이 초기화되면서 저장 버튼이 잠긴 채로 남는다.
const settingsLoaded = page.waitForResponse(
(res) =>
res.url().includes('/api/admin/plugins/sirsoft-verification_nhnkcp/settings') &&
res.request().method() === 'GET',
{ timeout: 30_000 },
);
await page.goto(SETTINGS_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const body = await (await settingsLoaded).json();
settingsSnapshot = (body?.data ?? {}) as Record<string, unknown>;
const siteCd = page.locator('[name="live_site_cd"]').first();
await expect(siteCd).toBeVisible({ timeout: 20_000 });
// 폼 바인딩이 응답값에 도달할 때까지 기다린다 (여기까지 오면 입력을 시작해도 안전하다).
await expect(siteCd).toHaveValue(String(settingsSnapshot.live_site_cd ?? ''), { timeout: 20_000 });
});
// @scenario mode=test,live_credentials=complete
@@ -35,19 +82,20 @@ test.describe('NHN KCP 본인확인 환경설정', () => {
// @scenario mode=live,live_credentials=missing_site_cd
// @effects live_mode_requires_site_cd_and_enc_key
test('운영 모드로 전환하면 경고 배너가 나타난다', async ({ page }) => {
const toggle = page.locator('[name="is_test_mode"]').first();
await expect(toggle).toBeVisible({ timeout: 20_000 });
await toggle.click();
await toggleSwitch(page, 'is_test_mode');
await expect(page.locator('[name="is_test_mode"]').first()).not.toBeChecked({ timeout: 10_000 });
await expect(page.getByText(/운영 모드|라이브/).first()).toBeVisible({ timeout: 10_000 });
});
// @scenario mode=live,live_credentials=missing_site_cd
// @effects live_mode_requires_site_cd_and_enc_key
test('운영 자격증명을 비운 채 저장하면 저장이 막히고 안내가 표시된다', async ({ page }) => {
const toggle = page.locator('[name="is_test_mode"]').first();
await toggle.click();
await toggleSwitch(page, 'is_test_mode');
// 운영 자격증명을 비운 상태를 명시적으로 만든다 (저장된 값이 있으면 422 가 나지 않는다).
await page.locator('[name="live_site_cd"]').first().fill('');
await page.locator('[name="live_enc_key"]').first().fill('');
const saveButton = page.getByRole('button', { name: /저장/ }).last();
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
@@ -63,4 +111,44 @@ test.describe('NHN KCP 본인확인 환경설정', () => {
expect(response.status()).toBe(422);
await expect(page.getByText(/사이트코드|암호화 키/).first()).toBeVisible({ timeout: 10_000 });
});
// @scenario mode=live,live_credentials=complete
// @effects sm_prefix_is_added_once
test('사이트코드에 SM 을 포함해 입력하면 저장 후 입력칸이 프리픽스 없는 값으로 바뀐다', async ({ page }) => {
const siteCd = page.locator('[name="live_site_cd"]').first();
const encKey = page.locator('[name="live_enc_key"]').first();
// 이 spec 은 실 환경 설정 파일에 쓴다. 암호화 키는 건드리지 않고(화면이 마스크를 그대로
// 되돌려 보내 종전 값이 보존된다), 바꾼 사이트코드는 끝에 원복한다.
const originalSiteCd = String(settingsSnapshot.live_site_cd ?? '');
const save = async (): Promise<number> => {
const button = page.getByRole('button', { name: /저장/ }).last();
await expect(button).toBeEnabled({ timeout: 10_000 });
const [response] = await Promise.all([
page.waitForResponse(
(res) => res.url().includes('/settings') && res.request().method() === 'PUT',
{ timeout: 20_000 },
),
button.click(),
]);
return response.status();
};
try {
await siteCd.fill('SMA1B2C');
expect(await save()).toBe(200);
// 입력칸 왼쪽에 SM 배지가 따로 있으므로, 프리픽스가 값에 남으면 화면에 SMSMA1B2C 로 보인다.
await expect(siteCd).toHaveValue('A1B2C', { timeout: 10_000 });
// 저장된 암호화 키는 화면에 평문으로 돌아오지 않는다 — 비어 있거나 마스크여야 한다.
expect(await encKey.getAttribute('type')).toBe('password');
const shownKey = await encKey.inputValue();
expect(shownKey === '' || /^•+$/.test(shownKey)).toBe(true);
} finally {
await siteCd.fill(originalSiteCd);
await save();
}
});
});
@@ -35,6 +35,43 @@ class KcpLiveSiteCdPrefixTest extends PluginTestCase
$this->assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('SMA1B2C'));
}
/**
* 운영자가 프리픽스를 소문자로 입력했을 때도 중복 부착되지 않아야 한다.
*
* 사이트코드 자체는 대문자로 발급되지만 프리픽스를 손으로 옮겨 적는 과정에서 소문자가
* 섞일 수 있다. 이때 프리픽스 판정이 대소문자를 구분하면 `SMsmA1B2C` 가 만들어져 모든
* 인증 요청이 잘못된 사이트코드로 나간다.
*
* @scenario mode=live,live_credentials=complete
*
* @effects sm_prefix_is_added_once
*/
public function test_prefix_is_not_duplicated_when_present_in_lowercase(): void
{
$this->assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('smA1B2C'));
$this->assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('SmA1B2C'));
$this->assertSame('SMA1B2C', $this->provider()->buildLiveSiteCd('sMA1B2C'));
}
/**
* 소문자 프리픽스가 이미 저장된 기설치본도 런타임에서 올바른 사이트코드를 내야 한다.
*
* @scenario mode=live,live_credentials=complete
*
* @effects sm_prefix_is_added_once
*/
public function test_stored_lowercase_prefix_resolves_to_canonical_site_cd(): void
{
$provider = $this->provider([
'is_test_mode' => false,
'live_site_cd' => 'smA1B2C',
'live_enc_key' => 'live-key',
]);
$this->assertTrue($provider->isAvailable());
$this->assertSame('SMA1B2C', $provider->resolveSiteCd());
}
/**
* @scenario mode=live,live_credentials=missing_site_cd
*
@@ -674,4 +674,53 @@ describe('트러블슈팅 회귀 테스트 - 데이터소스 업데이트 및
expect(getLocalInitTracking().hash).toBe('');
});
});
describe('[사례 7] 저장 후 refetch 했는데 입력칸만 예전 입력값을 유지', () => {
/**
* 증상: 서버가 저장 시 값을 정규화하는 화면에서 저장 성공 후 refetchDataSource 를 호출했는데
* 입력칸에는 사용자가 타이핑한 값이 그대로 남는다 (사이트코드 SMA1B2C → 저장 A1B2C,
* 입력칸은 계속 SMA1B2C. 왼쪽 SM 배지와 겹쳐 SMSMA1B2C 로 보인다).
* 원인: initLocal 미적용이 아니다. 브라우저 계측 결과 refetch 는 발생하고
* `_localInit applied (data changed): [form]` 도 찍히며 `_local.form` 은 정규화된 값을 갖는다.
* `_local` 갱신과 사용자가 이미 편집한 입력칸의 DOM 갱신이 별개다.
* 해결: 저장 성공 onSuccess 에서 응답 데이터로 폼을 명시 재바인딩
* (`setState` target=local, `form: "{{response.data}}"`).
*
* 본 테스트는 이 사례의 진단 근거 — "refetch 는 `_local` 을 갱신한다" — 를 잠근다.
* 이 전제가 깨지면 사례의 원인 분석("initLocal 문제가 아니다")이 더 이상 성립하지 않으므로
* 문서와 함께 재검토해야 한다.
*
* @see localInitSlot.ts - mergeLocalInitSlot (소비된 슬롯은 병합이 아니라 교체)
*/
beforeEach(() => {
resetLocalInitTracking();
});
afterEach(() => {
resetLocalInitTracking();
});
it('refetch 가 실어 온 정규화 값이 소비된 슬롯을 교체해 _local 에 도달해야 함', () => {
// 최초 로드: 저장돼 있던 값
const initial = { form: { live_site_cd: 'Z9Y8X' }, _forceLocalInit: 1700000000000 };
markLocalInitConsumed(initial);
// 저장 후 refetch: 서버가 정규화한 값 (refetchOnMount: true → 새 타임스탬프)
const afterSave = { form: { live_site_cd: 'A1B2C' }, _forceLocalInit: 1700000001000 };
const slot = mergeLocalInitSlot(initial, afterSave) as Record<string, any>;
// 소비된 슬롯은 병합이 아니라 교체 — 과거 값이 남아서는 안 된다
expect(slot.form.live_site_cd).toBe('A1B2C');
expect(slot).toBe(afterSave);
});
it('타임스탬프가 갱신되어 소비부 추적 키가 달라져야 함 (재적용 허용)', () => {
const before = `${JSON.stringify({ form: { live_site_cd: 'Z9Y8X' } })}:1700000000000`;
const after = `${JSON.stringify({ form: { live_site_cd: 'A1B2C' } })}:1700000001000`;
// DynamicRenderer 의 trackingKey 계산과 동일한 형태 — 값·타임스탬프 둘 다 달라 재적용된다
expect(after).not.toBe(before);
});
});
});
@@ -10,7 +10,6 @@
- 회원 상세에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 로그인 실패가 누적돼 잠긴 계정, 특히 무기한 잠긴 계정을 관리자가 이 화면에서 바로 풀 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 보안에 비밀번호 최소 길이·특수문자 필수 항목을 추가하고, 로그인 시도 제한 항목에 IP 단위 제한 계산 방식 안내를 덧붙였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 일반 설정에 에셋 파일 서빙 방식 항목을 추가했습니다. 정적 파일 최적화 규칙(nginx `location ~* \.(js|css|json|...)$` 블록 등)이 확장자 주소를 가로채 화면이 뜨지 않을 때 확장자 없는 주소로 전환할 수 있습니다. "자동 감지" 버튼을 누르면 판정 결과가 항목 아래에 인라인으로 표시되며, 확장자를 가로채는 환경이면 원인이 되는 서버 규칙까지 안내합니다. 평소에는 자동으로 선택되므로 화면이 정상이면 바꿀 필요가 없습니다.
- 저장된 에셋 파일 서빙 방식이 실제 서버 환경과 다르면 대시보드에서 안내합니다. 브라우저는 방식이 어긋나도 스스로 보정해 화면을 띄우지만 검색엔진 봇에는 보정이 적용되지 않으므로, 설정을 맞춰 두어야 검색 노출이 정상 유지됩니다. 안내에서 바로 환경설정으로 이동할 수 있습니다.
- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다.
@@ -20,6 +19,11 @@
- SEO 설정에서 sitemap 생성 진행 상황을 화면에서 확인할 수 있습니다. 대기·생성 중·파일 작성 중·완료·실패 단계와 현재 처리 중인 항목, 누적 URL 수가 표시됩니다. 실시간 연결(WebSocket)이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
- 언어팩 관리 목록에서 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 언어팩에 "파일 없음" 표시와 다시 설치 버튼을 추가했습니다. 목록에는 정상으로 보여도 번역이 적용되지 않던 상태를 바로 발견해 복구할 수 있습니다.
#### 접근성
- 상단 탭(환경설정, 게시판 관리 등)을 키보드만으로 넘길 수 있습니다. 탭에 초점이 있을 때 좌우 화살표로 이전/다음 탭으로 이동하고 Home/End 로 첫·마지막 탭으로 갑니다. 비활성 탭은 건너뜁니다. 화면리더에는 "탭 목록 중 몇 번째, 선택됨" 으로 안내됩니다. 이전에는 탭이 일반 버튼으로만 읽혀 목록이라는 사실과 선택 상태가 전달되지 않았습니다.
- 켜기/끄기 스위치를 키보드만으로 조작할 수 있습니다. Tab 키로 초점을 옮긴 뒤 Space 또는 Enter 로 전환합니다. 화면리더에는 스위치와 그 켜짐 여부로 안내됩니다. 이전에는 마우스로만 조작할 수 있었고 화면리더에는 아무것도 읽히지 않았습니다.
### Changed
- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 중에는 버튼이 비활성화되고 진행 상황이 화면에 표시됩니다.
@@ -38,7 +42,7 @@
- 저장이나 삭제가 실패했을 때 아무 안내 없이 조용히 넘어가던 화면들을 수정했습니다. 이제 실패하면 그 이유가 화면에 표시되므로, 반영되지 않은 변경을 성공한 것으로 오해하지 않습니다.
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
- 모듈·플러그인·템플릿·언어팩 목록과 일정 관리 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 다크 모드에서 일부 버튼·알림 점·아이콘의 색 대비가 낮아 잘 보이지 않던 문제를 수정했습니다.
- 다크 모드에서 일부 버튼·알림 점·아이콘·탭 뱃지의 색 대비가 낮아 잘 보이지 않던 문제를 수정했습니다.
- 템플릿 목록 화면의 탭 영역이 본문 여백과 어긋나 가로로 밀려 보일 수 있던 문제를 수정했습니다.
- 메일 템플릿·알림 정의 목록에서 각 행이 같은 식별자를 공유해 화면 읽기 도구가 항목을 구분하지 못하던 문제를 수정했습니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,6 +37,13 @@ export interface TabNavigationProps {
* **주의**: 이 컴포넌트는 순수 네비게이션 UI만 제공하며,
* 실제 탭 컨텐츠는 부모 컴포넌트에서 activeTabId를 기반으로 조건부 렌더링해야 합니다.
*
* 접근성: WAI-ARIA Tabs 규약을 따릅니다 — 목록에 `role="tablist"`, 각 탭에 `role="tab"` +
* `aria-selected` 를 부여하고, 활성 탭만 Tab 키 초점을 받는 roving tabindex 로 좌우 화살표와
* Home/End 로 탭 사이를 이동합니다(비활성 탭은 건너뜁니다). 탭 패널을 이 컴포넌트가 소유하지
* 않으므로 `aria-controls` 는 부여하지 않습니다 — 존재하지 않는 id 를 가리키면 보조기기가
* 패널을 찾지 못해 아예 없는 것보다 나쁩니다. 패널을 렌더하는 부모가 필요 시 연결합니다.
* 모바일 전환 시에는 native select 로 렌더되어 그 자체로 접근 가능합니다.
*
* 기본 컴포넌트 조합: Nav + Button + Icon + Div + Span + Select
*
* @example
@@ -32,5 +32,11 @@ export interface ToggleProps {
* Flowbite 스타일의 토글 스위치입니다.
* 라이트/다크 모드를 모두 지원합니다.
* 반응형으로 prop 변경을 추적합니다.
*
* 접근성: 실제 checkbox 는 sr-only 로 숨기고 보이는 트랙이 조작 대상이므로, 그 트랙에
* `role="switch"` + `aria-checked` + `tabIndex` 를 부여하고 Space/Enter 키를 처리합니다.
* 이 처리가 없으면 마우스 없이는 토글을 전혀 조작할 수 없고 보조기기에도 스위치로 읽히지
* 않습니다. 옆 라벨은 sibling 이라 `label[for]` 로 연결되지 않으므로 `aria-label` 로 이름을
* 직접 부여합니다.
*/
export declare const Toggle: React.FC<ToggleProps>;
@@ -146,12 +146,18 @@ const mainCssPath = path.join(__dirname, '../src/styles/main.css');
let mainCss = fs.readFileSync(mainCssPath, 'utf-8');
// 기존 safelist 마커 제거
//
// 삽입 블록은 앞뒤에 개행을 하나씩 두는데, 마커 사이만 잘라내면 그 개행 두 개가 남는다.
// 그대로 두면 빌드 1회마다 main.css 에 빈 줄 2개가 쌓여(실측: 373줄 누적) 매 빌드가 소스
// 파일을 변경된 것으로 만든다. 잘라낼 때 마커 주변 공백까지 함께 걷어낸다.
const markerStart = '/* SAFELIST-START */';
const markerEnd = '/* SAFELIST-END */';
const startIdx = mainCss.indexOf(markerStart);
const endIdx = mainCss.indexOf(markerEnd);
if (startIdx !== -1 && endIdx !== -1) {
mainCss = mainCss.slice(0, startIdx) + mainCss.slice(endIdx + markerEnd.length);
mainCss =
mainCss.slice(0, startIdx).replace(/\s+$/, '') +
mainCss.slice(endIdx + markerEnd.length).replace(/^\s+/, '\n\n');
}
// 새 safelist 삽입 (@source "../../layouts/**/*.json"; 바로 뒤에)
@@ -159,8 +165,7 @@ const classesStr = [...classes].sort().join(' ');
const safelistBlock = `
${markerStart}
@source inline("${classesStr}");
${markerEnd}
`;
${markerEnd}`;
// @source "../../layouts/**/*.json"; 뒤에 삽입
const layoutSourcePattern = /@source\s+"\.\.\/\.\.\/layouts\/\*\*\/\*\.json";/;
@@ -46,6 +46,13 @@ export interface TabNavigationProps {
* **주의**: 이 컴포넌트는 순수 네비게이션 UI만 제공하며,
* 실제 탭 컨텐츠는 부모 컴포넌트에서 activeTabId를 기반으로 조건부 렌더링해야 합니다.
*
* 접근성: WAI-ARIA Tabs 규약을 따릅니다 — 목록에 `role="tablist"`, 각 탭에 `role="tab"` +
* `aria-selected` 를 부여하고, 활성 탭만 Tab 키 초점을 받는 roving tabindex 로 좌우 화살표와
* Home/End 로 탭 사이를 이동합니다(비활성 탭은 건너뜁니다). 탭 패널을 이 컴포넌트가 소유하지
* 않으므로 `aria-controls` 는 부여하지 않습니다 — 존재하지 않는 id 를 가리키면 보조기기가
* 패널을 찾지 못해 아예 없는 것보다 나쁩니다. 패널을 렌더하는 부모가 필요 시 연결합니다.
* 모바일 전환 시에는 native select 로 렌더되어 그 자체로 접근 가능합니다.
*
* 기본 컴포넌트 조합: Nav + Button + Icon + Div + Span + Select
*
* @example
@@ -80,12 +87,58 @@ export const TabNavigation: React.FC<TabNavigationProps> = ({
? responsiveValue.width < mobileBreakpoint
: typeof window !== 'undefined' && window.innerWidth < mobileBreakpoint;
/** 탭 버튼 DOM 참조 — 화살표 이동 시 초점을 옮기기 위해 보관한다. */
const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const handleTabClick = (tab: Tab) => {
if (!tab.disabled && tab.id !== activeTabId) {
onTabChange?.(tab.id);
}
};
/**
* WAI-ARIA Tabs 키보드 규약 — 좌우 화살표로 탭 이동, Home/End 로 양 끝 이동.
*
* 비활성 탭은 건너뛴다. 활성 탭만 Tab 키 초점을 받는 roving tabindex 를 쓰므로,
* 탭 목록 안에서의 이동은 화살표가 담당한다.
*
* @param e 키보드 이벤트
* @param currentIndex 이벤트가 발생한 탭의 인덱스
*/
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
let nextIndex: number | null = null;
if (step !== 0) {
// 비활성 탭을 건너뛰며 순환한다.
for (let i = 1; i <= tabs.length; i += 1) {
const candidate = (currentIndex + step * i + tabs.length * i) % tabs.length;
if (!tabs[candidate]?.disabled) {
nextIndex = candidate;
break;
}
}
} else if (e.key === 'Home') {
nextIndex = tabs.findIndex((tab) => !tab.disabled);
} else if (e.key === 'End') {
for (let i = tabs.length - 1; i >= 0; i -= 1) {
if (!tabs[i]?.disabled) {
nextIndex = i;
break;
}
}
}
if (nextIndex === null || nextIndex < 0) {
return;
}
e.preventDefault();
tabRefs.current[nextIndex]?.focus();
handleTabClick(tabs[nextIndex]);
};
const handleSelectChange = (
e: React.ChangeEvent<HTMLSelectElement> | { target: { value: string | number } }
) => {
@@ -153,17 +206,31 @@ export const TabNavigation: React.FC<TabNavigationProps> = ({
const editorNodePath = editorAttrs?.['data-editor-path'];
// 데스크톱: 탭 버튼 단일 렌더
// 활성 탭이 없으면(초기 렌더 등) 첫 활성화 가능 탭이 Tab 키 초점을 받는다 — roving tabindex 에서
// 초점 받을 탭이 하나도 없으면 탭 목록 전체가 키보드로 진입 불가해진다.
const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId);
const focusableIndex = activeIndex >= 0 ? activeIndex : tabs.findIndex((tab) => !tab.disabled);
return (
<Nav
className={`${navClasses} ${className}`}
style={style}
role="tablist"
aria-orientation="horizontal"
id={id} {...editorAttrs}
>
{tabs.map((tab, tabIndex) => (
<Button
key={tab.id}
ref={(el) => {
tabRefs.current[tabIndex] = el;
}}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tabIndex === focusableIndex ? 0 : -1}
onClick={() => handleTabClick(tab)}
onKeyDown={(e) => handleTabKeyDown(e, tabIndex)}
disabled={tab.disabled}
className={getTabClasses(tab)}
{...(editorNodePath
@@ -177,7 +244,7 @@ export const TabNavigation: React.FC<TabNavigationProps> = ({
<Span>{tab.label}</Span>
{tab.badge !== undefined && (
<Div className="flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full">
<Div className="flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-red-500 dark:bg-red-600 text-white dark:text-white text-xs font-bold rounded-full">
<Span>{tab.badge}</Span>
</Div>
)}
@@ -127,6 +127,9 @@ export const TabNavigationScroll: React.FC<TabNavigationScrollProps> = ({
const isScrollingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
/** 탭 버튼 DOM 참조 — 화살표 이동 시 초점을 옮기기 위해 보관한다. */
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
/**
* 탭 클릭 핸들러
* 우선순위: 개별 탭 onClick > 공통 onTabChange > 기본 동작
@@ -185,6 +188,48 @@ export const TabNavigationScroll: React.FC<TabNavigationScrollProps> = ({
}
};
/**
* WAI-ARIA Tabs 키보드 규약 — 좌우 화살표로 탭 이동, Home/End 로 양 끝 이동.
*
* 비활성 탭은 건너뛴다. 활성 탭만 Tab 키 초점을 받는 roving tabindex 를 쓰므로 탭 목록
* 안에서의 이동은 화살표가 담당한다.
*
* @param e 키보드 이벤트
* @param currentIndex 이벤트가 발생한 탭의 인덱스
*/
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
let nextIndex: number | null = null;
if (step !== 0) {
for (let i = 1; i <= tabs.length; i += 1) {
const candidate = (currentIndex + step * i + tabs.length * i) % tabs.length;
if (!tabs[candidate]?.disabled) {
nextIndex = candidate;
break;
}
}
} else if (e.key === 'Home') {
nextIndex = tabs.findIndex((tab) => !tab.disabled);
} else if (e.key === 'End') {
for (let i = tabs.length - 1; i >= 0; i -= 1) {
if (!tabs[i]?.disabled) {
nextIndex = i;
break;
}
}
}
if (nextIndex === null || nextIndex < 0) {
return;
}
e.preventDefault();
tabRefs.current[nextIndex]?.focus();
handleTabClick(tabs[nextIndex]);
};
/**
* Scroll Spy 구현 (IntersectionObserver 사용)
*
@@ -358,15 +403,34 @@ export const TabNavigationScroll: React.FC<TabNavigationScrollProps> = ({
);
}
// 활성 탭이 없으면 첫 활성화 가능 탭이 Tab 키 초점을 받는다 (roving tabindex 진입점 보장).
const activeIndex = tabs.findIndex((tab) => tab.id === activeTab);
const focusableIndex = activeIndex >= 0 ? activeIndex : tabs.findIndex((tab) => !tab.disabled);
// 데스크톱: 탭 버튼 단일 렌더
return (
<Div className={className} style={style} id={id} {...editorAttrs}>
{tabs.map((tab) => (
<Div
className={className}
style={style}
role="tablist"
aria-orientation="horizontal"
id={id}
{...editorAttrs}
>
{tabs.map((tab, tabIndex) => (
<Button
key={tab.id}
ref={(el) => {
tabRefs.current[tabIndex] = el;
}}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
tabIndex={tabIndex === focusableIndex ? 0 : -1}
className={activeTab === tab.id ? activeClassName : inactiveClassName}
disabled={tab.disabled}
onClick={() => handleTabClick(tab)}
onKeyDown={(e) => handleTabKeyDown(e, tabIndex)}
>
{tab.iconName && (
<Icon name={tab.iconName} size="sm" />
@@ -40,6 +40,12 @@ export interface ToggleProps {
* Flowbite 스타일의 토글 스위치입니다.
* 라이트/다크 모드를 모두 지원합니다.
* 반응형으로 prop 변경을 추적합니다.
*
* 접근성: 실제 checkbox 는 sr-only 로 숨기고 보이는 트랙이 조작 대상이므로, 그 트랙에
* `role="switch"` + `aria-checked` + `tabIndex` 를 부여하고 Space/Enter 키를 처리합니다.
* 이 처리가 없으면 마우스 없이는 토글을 전혀 조작할 수 없고 보조기기에도 스위치로 읽히지
* 않습니다. 옆 라벨은 sibling 이라 `label[for]` 로 연결되지 않으므로 `aria-label` 로 이름을
* 직접 부여합니다.
*/
export const Toggle: React.FC<ToggleProps> = ({
checked: checkedProp,
@@ -106,6 +112,23 @@ export const Toggle: React.FC<ToggleProps> = ({
}
};
/**
* 키보드 조작 — Space / Enter 로 전환한다 (WAI-ARIA switch 규약).
*
* 실제 checkbox 는 sr-only + tabIndex=-1 이라 초점을 받지 않으므로, 초점을 받는 래퍼가
* 키 입력을 처리해야 한다. 이 처리가 없으면 마우스 없이는 토글을 전혀 쓸 수 없다.
*
* @param e 키보드 이벤트
*/
const handleToggleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (e.key !== ' ' && e.key !== 'Enter' && e.key !== 'Spacebar') return;
// Space 는 기본 동작이 스크롤이므로 막는다.
e.preventDefault();
handleToggleClick();
};
return (
<div className={`toggle-container ${className}`} id={id} {...editorAttrs}>
{/* 토글 스위치 */}
@@ -113,6 +136,12 @@ export const Toggle: React.FC<ToggleProps> = ({
className={`toggle-switch-wrapper ${
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
}`}
role="switch"
aria-checked={checked}
aria-disabled={disabled || undefined}
aria-label={label ?? name}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleToggleKeyDown}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -374,4 +374,99 @@ describe('TabNavigation', () => {
expect(container.querySelectorAll('[data-editor-item-path]').length).toBe(0);
});
});
/**
* 접근성 — WAI-ARIA Tabs 규약.
*
* 탭이 평범한 버튼으로만 렌더되면 보조기기가 "탭 목록 중 몇 번째, 선택됨" 을 알릴 수 없고,
* 목록 안 이동도 Tab 키로 전 탭을 훑어야 한다.
*/
describe('접근성 (WAI-ARIA Tabs)', () => {
it('목록은 tablist, 각 항목은 tab 으로 노출된다', () => {
render(<TabNavigation tabs={mockTabs} activeTabId={2} />);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getAllByRole('tab')).toHaveLength(3);
});
it('활성 탭만 aria-selected=true 다', () => {
render(<TabNavigation tabs={mockTabs} activeTabId={2} />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
});
it('활성 탭만 Tab 키 초점을 받는다 (roving tabindex)', () => {
render(<TabNavigation tabs={mockTabs} activeTabId={2} />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('tabindex', '-1');
expect(tabs[1]).toHaveAttribute('tabindex', '0');
expect(tabs[2]).toHaveAttribute('tabindex', '-1');
});
it('활성 탭이 지정되지 않아도 첫 탭이 초점 진입점이 된다', () => {
render(<TabNavigation tabs={mockTabs} />);
expect(screen.getAllByRole('tab')[0]).toHaveAttribute('tabindex', '0');
});
it('좌우 화살표로 이전/다음 탭으로 이동한다', () => {
const onTabChange = vi.fn();
render(<TabNavigation tabs={mockTabs} activeTabId={2} onTabChange={onTabChange} />);
const tabs = screen.getAllByRole('tab');
fireEvent.keyDown(tabs[1], { key: 'ArrowRight' });
expect(onTabChange).toHaveBeenLastCalledWith(3);
fireEvent.keyDown(tabs[1], { key: 'ArrowLeft' });
expect(onTabChange).toHaveBeenLastCalledWith(1);
});
it('화살표 이동은 양 끝에서 순환한다', () => {
const onTabChange = vi.fn();
render(<TabNavigation tabs={mockTabs} activeTabId={3} onTabChange={onTabChange} />);
fireEvent.keyDown(screen.getAllByRole('tab')[2], { key: 'ArrowRight' });
expect(onTabChange).toHaveBeenLastCalledWith(1);
});
it('Home/End 로 첫/마지막 탭으로 이동한다', () => {
const onTabChange = vi.fn();
render(<TabNavigation tabs={mockTabs} activeTabId={2} onTabChange={onTabChange} />);
const tabs = screen.getAllByRole('tab');
fireEvent.keyDown(tabs[1], { key: 'End' });
expect(onTabChange).toHaveBeenLastCalledWith(3);
fireEvent.keyDown(tabs[1], { key: 'Home' });
expect(onTabChange).toHaveBeenLastCalledWith(1);
});
it('화살표 이동은 비활성 탭을 건너뛴다', () => {
const onTabChange = vi.fn();
const withDisabled: Tab[] = [
{ id: 1, label: '프로필' },
{ id: 2, label: '설정', disabled: true },
{ id: 3, label: '알림' },
];
render(<TabNavigation tabs={withDisabled} activeTabId={1} onTabChange={onTabChange} />);
fireEvent.keyDown(screen.getAllByRole('tab')[0], { key: 'ArrowRight' });
expect(onTabChange).toHaveBeenLastCalledWith(3);
});
it('모바일 전환 시에는 tablist 대신 Select 드롭다운으로 렌더된다', () => {
mockUseResponsive.mockReturnValueOnce({ width: 500 } as any);
const { container } = render(<TabNavigation tabs={mockTabs} activeTabId={1} />);
// 모바일은 탭 목록이 아니라 단일 선택 UI 이므로 tablist/tab 을 두지 않는다.
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
expect(screen.queryAllByRole('tab')).toHaveLength(0);
// custom Select 는 trigger button 을 갖는다 (같은 파일의 모바일 렌더 테스트와 동일 관례)
expect(container.querySelector('button')).toBeInTheDocument();
});
});
});
@@ -101,11 +101,13 @@ describe('TabNavigationScroll', () => {
/**
* 헬퍼 함수: 데스크톱 뷰의 탭 버튼을 찾습니다.
* 단일 분기 렌더(useResponsive 기반)이므로 데스크톱 모드에서는 Nav 안의 버튼만 존재합니다.
*
* 단일 분기 렌더(useResponsive 기반)이므로 데스크톱 모드에서는 tablist 안의 탭만 존재합니다.
* 탭은 WAI-ARIA 규약에 따라 role="tab" 으로 노출되므로 button 이 아니라 tab 으로 찾습니다.
*/
const getTabButton = (text: string): HTMLButtonElement | null => {
const buttons = screen.getAllByRole('button');
return buttons.find((btn) => btn.textContent === text) as HTMLButtonElement | null;
const tabs = screen.getAllByRole('tab');
return tabs.find((tab) => tab.textContent === text) as HTMLButtonElement | null;
};
describe('기본 렌더링', () => {
@@ -679,4 +681,67 @@ describe('TabNavigationScroll', () => {
expect(permissionsButton).toBeDisabled();
});
});
/**
* 접근성 — WAI-ARIA Tabs 규약.
*
* 탭이 평범한 버튼으로만 렌더되면 보조기기가 "탭 목록 중 몇 번째, 선택됨" 을 알릴 수 없고,
* 목록 안 이동도 Tab 키로 전 탭을 훑어야 한다.
*/
describe('접근성 (WAI-ARIA Tabs)', () => {
it('목록은 tablist, 각 항목은 tab 으로 노출된다', () => {
render(<TabNavigationScroll tabs={mockTabs} activeTabId="list" />);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getAllByRole('tab')).toHaveLength(3);
});
it('활성 탭만 aria-selected=true 이고 Tab 키 초점을 받는다', () => {
render(<TabNavigationScroll tabs={mockTabs} activeTabId="list" />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
expect(tabs[0]).toHaveAttribute('tabindex', '-1');
expect(tabs[1]).toHaveAttribute('tabindex', '0');
});
it('좌우 화살표로 이동하며 비활성 탭은 건너뛴다', () => {
const onTabChange = vi.fn();
const withDisabled: Tab[] = [
{ id: 'basic', label: '기본정보' },
{ id: 'list', label: '목록설정', disabled: true },
{ id: 'permissions', label: '권한설정' },
];
render(
<TabNavigationScroll tabs={withDisabled} activeTabId="basic" onTabChange={onTabChange} />
);
fireEvent.keyDown(screen.getAllByRole('tab')[0], { key: 'ArrowRight' });
expect(onTabChange).toHaveBeenLastCalledWith('permissions');
});
it('Home/End 로 첫/마지막 탭으로 이동한다', () => {
const onTabChange = vi.fn();
render(
<TabNavigationScroll tabs={mockTabs} activeTabId="list" onTabChange={onTabChange} />
);
const tabs = screen.getAllByRole('tab');
fireEvent.keyDown(tabs[1], { key: 'End' });
expect(onTabChange).toHaveBeenLastCalledWith('permissions');
fireEvent.keyDown(tabs[1], { key: 'Home' });
expect(onTabChange).toHaveBeenLastCalledWith('basic');
});
it('모바일 전환 시에는 tablist/tab 을 두지 않는다', () => {
setMobile();
render(<TabNavigationScroll tabs={mockTabs} activeTabId="basic" />);
// 모바일은 탭 목록이 아니라 단일 선택 UI 이므로 tablist 규약을 적용하지 않는다.
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
expect(screen.queryAllByRole('tab')).toHaveLength(0);
});
});
});
@@ -158,4 +158,63 @@ describe('Toggle', () => {
const track = container.querySelector('.toggle-switch-track');
expect(track).toBeInTheDocument();
});
describe('접근성 — 실제 checkbox 가 sr-only 이므로 보이는 트랙이 스위치 역할을 해야 한다', () => {
it('스위치로 노출되고 상태와 이름을 함께 알린다', () => {
render(<Toggle checked={true} label="알림 활성화" />);
const switchEl = screen.getByRole('switch', { name: '알림 활성화' });
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('라벨이 없으면 name 으로 이름을 부여한다', () => {
render(<Toggle name="use_file_upload" />);
expect(screen.getByRole('switch', { name: 'use_file_upload' })).toBeInTheDocument();
});
it('Tab 키 초점을 받는다 (마우스 없이 도달 가능)', () => {
render(<Toggle label="알림 활성화" />);
expect(screen.getByRole('switch')).toHaveAttribute('tabindex', '0');
});
it('Space 키로 전환된다', async () => {
const onChange = vi.fn();
render(<Toggle label="알림 활성화" onChange={onChange} />);
const switchEl = screen.getByRole('switch');
switchEl.focus();
await userEvent.keyboard(' ');
expect(onChange).toHaveBeenCalledTimes(1);
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('Enter 키로 전환된다', async () => {
const onChange = vi.fn();
render(<Toggle label="알림 활성화" onChange={onChange} />);
const switchEl = screen.getByRole('switch');
switchEl.focus();
await userEvent.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledTimes(1);
expect(switchEl).toHaveAttribute('aria-checked', 'true');
});
it('disabled 면 초점을 받지 않고 키 입력도 무시한다', async () => {
const onChange = vi.fn();
render(<Toggle label="알림 활성화" disabled={true} onChange={onChange} />);
const switchEl = screen.getByRole('switch');
expect(switchEl).toHaveAttribute('tabindex', '-1');
expect(switchEl).toHaveAttribute('aria-disabled', 'true');
switchEl.focus();
await userEvent.keyboard(' ');
expect(onChange).not.toHaveBeenCalled();
});
});
});
File diff suppressed because one or more lines are too long
@@ -73,6 +73,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -62,7 +62,8 @@ test.describe('@sirsoft-admin_basic 관리자 목록 컨텍스트 왕복 (#75)',
await page.goto('/admin/templates/user?search=sirsoft&page=2');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#tab_navigation').getByRole('button').nth(1).click();
// 탭은 WAI-ARIA 규약에 따라 role="tab" 으로 노출된다 (일반 button 이 아니다).
await page.locator('#tab_navigation').getByRole('tab').nth(1).click();
await page.waitForURL(/\/admin\/templates\/admin/, { timeout: 30_000 });
const q = queryOf(page.url());
@@ -87,6 +87,12 @@ export default defineConfig({
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,