fix(page): 버전 이력 변경내역을 다국어 라벨로 표시 + E2E 인프라 신설
- 페이지 상세 버전 이력의 "변경내역"이 title·content 등 영문 필드명으로 노출되던 문제를 field_labels 다국어 매핑으로 정정 (ko/en/ja) - sirsoft-page 모듈에 Playwright E2E 인프라를 이커머스와 동일 패턴으로 신설 (config·fixture·seed stub + spec 3건, data-testid 보강 전까지 skip placeholder) - 출시된 1.0.0 이후 변경이므로 module 1.0.1 bump (3곳+lock 동기화), ja 언어팩 1.0.1 bump
This commit is contained in:
1 parent
4c4f90a091
commit
8cf55ad88f
24 files changed
+558
-26
No files matched your search
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- 페이지 버전 이력의 변경 필드명 라벨 일본어 번역 추가 (`versions.field_labels.*`) — 버전 이력의 "변경내역" 항목이 일본어 로케일에서 필드 키 대신 자연스러운 라벨로 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -160,7 +160,13 @@
|
||||
"changed_fields": "変更: {{fields}}",
|
||||
"restored_from": "v{{version}}から復元",
|
||||
"restore_guide": "表示ボタンをクリックすると、内容を確認して復元できます。添付ファイルはバージョン管理に含まれていません。",
|
||||
"preview_modal_editor_label": "バージョンプレビュー"
|
||||
"preview_modal_editor_label": "バージョンプレビュー",
|
||||
"field_labels": {
|
||||
"title": "タイトル",
|
||||
"content": "内容",
|
||||
"seo_meta": "SEO情報",
|
||||
"content_mode": "編集モード"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-page",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
- 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다.
|
||||
- 페이지 관리 목록의 페이지당 표시 개수 기본값이 화면 표시(20건)와 실제 조회(15건) 간에 어긋나던 문제를 수정했습니다 — 기본값을 20건으로 통일했습니다.
|
||||
- 페이지 관리 목록에서 아주 긴 검색어를 입력하면 오류 화면이 뜨던 문제를 수정했습니다 — 검색어 길이를 100자로 제한해 안내 메시지로 처리합니다.
|
||||
- 페이지 상세의 버전 이력에서 "변경내역" 항목이 `title`·`content` 같은 영문 필드명으로 표시되던 문제를 수정했습니다 — 이제 "제목", "내용", "SEO 정보" 처럼 알아보기 쉬운 이름으로 표시됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-page",
|
||||
"description": "Page module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+43
-2
@@ -569,7 +569,7 @@ describe('admin_page_form.json', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Issue #424: 수정→등록 SPA 전환 시 폼/첨부 잔존 차단 ───
|
||||
// ─── 수정→등록 SPA 전환 시 폼/첨부 잔존 차단 ───
|
||||
|
||||
it('FileUploader initialFiles 가 _local.form.attachments 에 바인딩됨 (등록 모드 전환 시 빈 배열로 동기화되어 이전 첨부 잔존 차단)', () => {
|
||||
// 회귀 배경: pageData?.data?.attachments 바인딩은 등록 모드에서 pageData 가 없어
|
||||
@@ -623,7 +623,7 @@ describe('admin_page_form.json', () => {
|
||||
expect(hintSpan.text).not.toContain("?? 'example'");
|
||||
});
|
||||
|
||||
// ─── Issue #424 6-2: 첨부 순서 — form.attachments 동기화 (이커머스 패턴) ───
|
||||
// ─── 첨부 순서 — form.attachments 동기화 (이커머스 패턴) ───
|
||||
|
||||
it('FileUploader 에 onUploadComplete/onReorder/onRemove 액션이 form.attachments 를 동기화함', () => {
|
||||
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
|
||||
@@ -791,4 +791,45 @@ describe('admin_page_detail.json', () => {
|
||||
// $locale 로 fallback 하는 표현식이 존재해야 함 (탭 + 콘텐츠 표현식)
|
||||
expect(layoutStr).toMatch(/_local\.lang \?\? \$locale/);
|
||||
});
|
||||
|
||||
// ─── 변경내역 필드명 다국어 매핑 ───
|
||||
|
||||
/**
|
||||
* 버전 이력 테이블의 변경내역(changes_summary) 컬럼에서
|
||||
* changed_fields 를 표시하는 cellChild 를 찾는다.
|
||||
*/
|
||||
function findChangedFieldsCell(): any {
|
||||
const grids = findComponentsByName(adminPageDetail, 'DataGrid');
|
||||
const col = grids[0]?.props?.columns?.find((c: any) => c.field === 'changes_summary');
|
||||
expect(col, 'changes_summary 컬럼').toBeDefined();
|
||||
// changed_fields 길이 > 0 조건을 가진 cellChild (라벨 표시 셀)
|
||||
return (col.cellChildren ?? []).find(
|
||||
(cc: any) =>
|
||||
typeof cc.if === 'string' &&
|
||||
cc.if.includes('changed_fields?.length > 0'),
|
||||
);
|
||||
}
|
||||
|
||||
it('변경내역 셀이 changed_fields 를 raw 영문 식별자로 그대로 join 하지 않음 (제목/내용 다국어 매핑)', () => {
|
||||
const cell = findChangedFieldsCell();
|
||||
expect(cell, 'changed_fields 표시 셀').toBeDefined();
|
||||
// 회귀 차단: 영문 식별자 배열을 그대로 join 하면 화면에 title, content 노출
|
||||
expect(cell.text).not.toMatch(/changed_fields\?\.join\(', '\)\s*\?\?\s*'-'/);
|
||||
});
|
||||
|
||||
it('변경내역 셀이 field_labels 다국어 키로 각 필드를 변환함 ($t 매핑)', () => {
|
||||
const cell = findChangedFieldsCell();
|
||||
expect(cell, 'changed_fields 표시 셀').toBeDefined();
|
||||
const cellStr = JSON.stringify(cell);
|
||||
// field_labels 키 경로 + $t 함수 변환 사용
|
||||
expect(cellStr).toContain('field_labels');
|
||||
expect(cellStr).toContain('$t');
|
||||
});
|
||||
|
||||
it('field_labels 4종(title/content/seo_meta/content_mode) 키 경로를 참조함 (PageService compareFields 정합)', () => {
|
||||
const cell = findChangedFieldsCell();
|
||||
const cellStr = JSON.stringify(cell);
|
||||
// 동적 키 prefix 가 versions.field_labels 를 가리킴
|
||||
expect(cellStr).toContain('sirsoft-page.admin.page.detail.versions.field_labels');
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,13 @@
|
||||
"initial": "Initial",
|
||||
"changed_fields": "Changed: {{fields}}",
|
||||
"restored_from": "Restored from v{{version}}",
|
||||
"restore_guide": "Click View to check contents and restore. Attachments are not included in version management."
|
||||
"restore_guide": "Click View to check contents and restore. Attachments are not included in version management.",
|
||||
"field_labels": {
|
||||
"title": "Title",
|
||||
"content": "Content",
|
||||
"seo_meta": "SEO",
|
||||
"content_mode": "Edit Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,13 @@
|
||||
"initial": "최초 생성",
|
||||
"changed_fields": "변경: {{fields}}",
|
||||
"restored_from": "v{{version}}에서 복원",
|
||||
"restore_guide": "보기 버튼을 클릭하면 내용을 확인하고 복원할 수 있습니다. 첨부파일은 버전 관리에 포함되지 않습니다."
|
||||
"restore_guide": "보기 버튼을 클릭하면 내용을 확인하고 복원할 수 있습니다. 첨부파일은 버전 관리에 포함되지 않습니다.",
|
||||
"field_labels": {
|
||||
"title": "제목",
|
||||
"content": "내용",
|
||||
"seo_meta": "SEO 정보",
|
||||
"content_mode": "편집 모드"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1665,7 +1665,7 @@
|
||||
"props": {
|
||||
"className": "text-sm text-gray-600 dark:text-gray-400"
|
||||
},
|
||||
"text": "{{row.changes_summary?.changed_fields?.join(', ') ?? '-'}}"
|
||||
"text": "{{row.changes_summary?.changed_fields?.map(f => $t('sirsoft-page.admin.page.detail.versions.field_labels.' + f)).join(', ') ?? '-'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
@@ -740,7 +740,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "드래그 순서 변경 시 form.attachments 를 최종 순서로 덮어쓰기 (이커머스 상품 이미지와 동일 패턴, 이슈 #424 6-2)",
|
||||
"comment": "드래그 순서 변경 시 form.attachments 를 최종 순서로 덮어쓰기 (이커머스 상품 이미지와 동일 패턴)",
|
||||
"event": "onReorder",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 페이지 도메인 시드 커맨드 (stub).
|
||||
*
|
||||
* 페이지 + 첨부 등 모듈 도메인 데이터를 발급하여 stdout 에 JSON 으로 반환한다. 본 stub 은
|
||||
* 시그니처와 보안 가드만 구현되어 있으며, 실제 시드 로직은 페이지 모듈 후속 작업 세션에서
|
||||
* Factory 재사용으로 채워질 예정 (이커머스 PlaywrightSeedEcommerce 와 동일 패턴).
|
||||
*
|
||||
* 보안 가드 (코어 PlaywrightIssueToken 과 동일 3중 패턴):
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'`
|
||||
* ② G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인
|
||||
* ③ APP_DEBUG=true inline override — production + debug=false 환경에서도 동작
|
||||
*
|
||||
* 호출 예시 (활성화 후):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-page --pages=3 --json
|
||||
*/
|
||||
class PlaywrightSeedPage extends Command
|
||||
{
|
||||
/**
|
||||
* 커맨드 이름 및 시그니처
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'playwright:seed-page
|
||||
{--pages=0 : 발급할 페이지 수}
|
||||
{--attachments=0 : 페이지당 발급할 첨부 수}
|
||||
{--json : 결과를 JSON 으로 출력}';
|
||||
|
||||
/**
|
||||
* 커맨드 설명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Playwright E2E 용 페이지 도메인 데이터 시드 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* 본 stub 은 가드 통과 후 LogicException 을 throw 한다.
|
||||
* 페이지 모듈 후속 작업 세션에서 stub 본문을 실제 시드 로직으로 교체할 때까지 활성화 차단.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ③ APP_DEBUG 강제 — SettingsServiceProvider 의 bypass 분기가 settings JSON 덮어쓰기 차단 상태
|
||||
Config::set('app.debug', true);
|
||||
|
||||
// TODO (페이지 모듈 후속 작업 세션):
|
||||
// - 페이지 N개 생성 (Factory 재사용)
|
||||
// - 페이지당 첨부 N개 생성 (업로드 순서 검증용, order 1..N 부여)
|
||||
// - 반환: {pageIds: [...], attachmentIds: [...]}
|
||||
// 개발자 전용 stub 메시지 — 사용자 노출 0 (본문 구현 시 즉시 삭제).
|
||||
throw new \LogicException( /* i18n:exempt — developer-only stub */
|
||||
'playwright:seed-page 시드 로직은 아직 구현되지 않았습니다. '
|
||||
.'대상 spec 을 test.describe.skip 로 유지하거나, 본 커맨드 본문을 실제 시드 로직으로 교체하세요.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 페이지 모듈 권한 fixture.
|
||||
*
|
||||
* 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼(issueToken / authenticatePage)를 재사용하되,
|
||||
* 모듈 권한 토큰 fixture 를 자체적으로 정의한다. 권한 식별자는 임의 string 이므로 코어
|
||||
* PlaywrightIssueToken 커맨드가 그대로 동작한다 (Permission::firstOrCreate 가 자동 생성).
|
||||
*
|
||||
* 권한 식별자 (module src 정의):
|
||||
* sirsoft-page.pages.read / create / update / delete
|
||||
*
|
||||
* 활성화 절차 (페이지 모듈 후속 작업 세션):
|
||||
* 1. 대상 화면 컴포넌트에 data-testid 보강 (각 spec docblock 의 "활성화 절차" 참조)
|
||||
* 2. 해당 spec 의 test.describe.skip → test.describe 변경
|
||||
*/
|
||||
import { test as base } from '@playwright/test';
|
||||
// 6단계 상위 = 코어 루트의 fixtures/auth.ts
|
||||
// (modules/_bundled/sirsoft-page/tests/Playwright/fixtures → 코어 루트)
|
||||
import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
|
||||
|
||||
type PageAuthFixtures = {
|
||||
/** 페이지 조회 권한 토큰 (목록/상세 조회 검증용) */
|
||||
pageReadToken: string;
|
||||
/** 페이지 조회 + 생성 권한 토큰 (등록 폼 검증용) */
|
||||
pageCreateToken: string;
|
||||
/** 페이지 조회 + 수정 권한 토큰 (수정 폼/첨부/버전 검증용) */
|
||||
pageManageToken: string;
|
||||
/** 권한 없는 일반 사용자 토큰 (메뉴/화면 미노출 검증용) */
|
||||
noPermissionToken: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<PageAuthFixtures>({
|
||||
pageReadToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-page.pages.read'));
|
||||
},
|
||||
pageCreateToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-page.pages.read', 'sirsoft-page.pages.create'));
|
||||
},
|
||||
pageManageToken: async ({}, use) => {
|
||||
await use(
|
||||
issueToken(
|
||||
'sirsoft-page.pages.read',
|
||||
'sirsoft-page.pages.create',
|
||||
'sirsoft-page.pages.update'
|
||||
)
|
||||
);
|
||||
},
|
||||
noPermissionToken: async ({}, use) => {
|
||||
// 빈 권한 — 인증만 통과, 어떤 모듈 권한도 없음
|
||||
await use(issueToken());
|
||||
},
|
||||
});
|
||||
|
||||
export { authenticatePage };
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 페이지 모듈 도메인 시드 fixture (stub).
|
||||
*
|
||||
* `playwright:seed-page` artisan 커맨드를 호출하여 도메인 데이터(페이지 + 첨부)를 발급받는다.
|
||||
* 본 stub 은 시드 커맨드가 LogicException 을 throw 하도록 구성되어 있어(시그니처/보안 가드만 구현)
|
||||
* 실제 사용 전에는 대상 spec 이 test.describe.skip 로 비활성화되어야 한다.
|
||||
*
|
||||
* 활성화 절차 (페이지 모듈 후속 작업 세션):
|
||||
* 1. PlaywrightSeedPage 의 handle() 본문을 LogicException 에서 실제 시드 로직으로 교체
|
||||
* (Page 모델 + 첨부 Factory 재사용, 업로드 순서 검증용 첨부 N건 포함)
|
||||
* 2. 본 fixture 를 사용하는 spec 의 test.describe.skip 표기 제거
|
||||
* 3. teardown 시 cleanup 커맨드(예: playwright:cleanup-page) 호출 추가 권장
|
||||
*/
|
||||
import { test as base } from '@playwright/test';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ESM 환경(package.json "type": "module")에서는 __dirname 미정의 → import.meta.url 로 재구성.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
type SeedFixtures = {
|
||||
/** 시드된 페이지 도메인 데이터 (페이지 ID + 첨부 ID 배열) */
|
||||
seededPage: { pageIds: number[]; attachmentIds: number[] };
|
||||
};
|
||||
|
||||
export const test = base.extend<SeedFixtures>({
|
||||
seededPage: async ({}, use) => {
|
||||
// 6단계 상위 = 코어 루트 (artisan 실행 cwd)
|
||||
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../');
|
||||
const out = execSync('php artisan playwright:seed-page --pages=3 --attachments=3 --json', {
|
||||
cwd: coreRoot,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
G7_PLAYWRIGHT_BYPASS: '1',
|
||||
},
|
||||
});
|
||||
const seed = JSON.parse(out);
|
||||
await use(seed);
|
||||
// TODO: teardown 시 cleanup 커맨드 호출
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 페이지 모듈 Playwright E2E 설정.
|
||||
*
|
||||
* 코어 `playwright.config.ts` 및 이커머스 모듈 설정과 동일한 base URL 해석 우선순위를 따른다 —
|
||||
* 모듈도 활성 호스트가 가변(개발자/CI/PO 환경별로 다른 도메인)이므로 하드코딩 회피.
|
||||
*
|
||||
* Base URL 해석:
|
||||
* 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드)
|
||||
* 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합
|
||||
* 3. 그 외 — 명시 에러
|
||||
*
|
||||
* 실행 예시:
|
||||
* PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e
|
||||
* Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e
|
||||
*
|
||||
* 모듈은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는
|
||||
* `sirsoft-page.*` 등 임의 string. 시드는 모듈 자체 artisan 커맨드(playwright:seed-page) 호출.
|
||||
*/
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로
|
||||
// import.meta.url 로 재구성한다.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readEnvFile(filePath: string, key: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = readFileSync(filePath, { encoding: 'utf-8' });
|
||||
const pattern = new RegExp(`^${key}=(.*)$`, 'm');
|
||||
const match = content.match(pattern);
|
||||
if (!match) return null;
|
||||
let value = match[1].trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (process.env.PLAYWRIGHT_BASE_URL) {
|
||||
return process.env.PLAYWRIGHT_BASE_URL;
|
||||
}
|
||||
// 모듈 디렉토리 기준 5단계 상위 = 코어 루트 (.env 위치)
|
||||
// (modules/_bundled/sirsoft-page/tests/Playwright → 코어 루트)
|
||||
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
|
||||
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
|
||||
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
|
||||
return appUrl;
|
||||
}
|
||||
throw new Error(
|
||||
'페이지 모듈 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.'
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: resolveBaseUrl(),
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 페이지 첨부 다중 업로드 순서 / 드래그 재정렬 → form.attachments 동기화
|
||||
* (placeholder — data-testid 보강 후 활성화).
|
||||
*
|
||||
* @scenario upload_count=single, upload_count=multiple, form_mode=create, form_mode=edit
|
||||
* @effects temp_attachments_linked_in_upload_order_1_to_n,
|
||||
* edit_mode_drag_persists_via_reorder_api
|
||||
*
|
||||
* e2e:allow 다중 업로드 시 첨부가 올린 순서대로 저장되고(생성 모드 temp_key 연결),
|
||||
* 수정 모드 드래그 재정렬이 reorder API 로 즉시 저장되어 form.attachments 에 반영되는
|
||||
* 동작을 백엔드 Feature/Unit 테스트와 레이아웃 렌더 테스트가 커버한다. 본 placeholder
|
||||
* spec(test.describe.skip)은 data-testid 보강 후 활성화된다.
|
||||
* - 생성 모드 업로드 순서 1..N 재부여: PageAttachmentServiceTest (unit)
|
||||
* - onUploadComplete/onReorder/onRemove → form.attachments 동기화:
|
||||
* admin-page-layouts.test.ts > "첨부 순서 — form.attachments 동기화" (레이아웃 렌더)
|
||||
* 브라우저 실제 드래그 인터랙션의 순서 영속만 본 spec 이 확인한다.
|
||||
*
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
|
||||
* 1. FileUploader 첨부 아이템에 data-testid="page-attachment-item" (순서 assert 용)
|
||||
* 2. 첨부 아이템 드래그 핸들에 data-testid="page-attachment-drag-handle"
|
||||
* 3. seededPage fixture(playwright:seed-page) 실 시드 로직 구현 (첨부 N건 order 1..N)
|
||||
* 4. test.describe.skip → test.describe 변경
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 upload_count × form_mode 와 1:1):
|
||||
* - create × multiple: 업로드(조회) 순서대로 저장 후 목록 순서 유지
|
||||
* - edit × multiple: 드래그로 순서 변경 후 재조회 시 변경 순서 영속
|
||||
*/
|
||||
import { mergeTests } from '@playwright/test';
|
||||
import { test as authTest, expect, authenticatePage } from '../../fixtures/page-auth';
|
||||
import { test as seedTest } from '../../fixtures/page-seed';
|
||||
|
||||
const test = mergeTests(authTest, seedTest);
|
||||
|
||||
const CREATE_URL = '/admin/pages/create';
|
||||
const EDIT_URL = '/admin/pages/1/edit';
|
||||
|
||||
test.describe.skip('페이지 첨부 순서 동기화 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test('등록 폼에서 여러 첨부를 올리면 올린 순서대로 목록에 표시된다', async ({
|
||||
page,
|
||||
pageCreateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageCreateToken);
|
||||
await page.goto(CREATE_URL);
|
||||
|
||||
// (data-testid 보강 후) 파일 3건을 순차 업로드하고 목록 순서가 업로드 순서와 일치하는지 확인
|
||||
await expect(page.getByTestId('page-attachment-item')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('수정 폼에서 첨부를 드래그로 재정렬하면 변경된 순서가 영속된다', async ({
|
||||
page,
|
||||
pageManageToken,
|
||||
seededPage,
|
||||
}) => {
|
||||
await authenticatePage(page, pageManageToken);
|
||||
await page.goto(EDIT_URL);
|
||||
|
||||
const items = page.getByTestId('page-attachment-item');
|
||||
await expect(items).toHaveCount(seededPage.attachmentIds.length);
|
||||
|
||||
// (data-testid 보강 후) 첫 아이템을 마지막으로 드래그 → reorder API 발화 → 재조회 시 순서 영속
|
||||
// 드래그 인터랙션 + 재조회 후 순서 assert 는 활성화 시 구현
|
||||
});
|
||||
});
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 페이지 수정→등록 라우트 전환 시 폼/첨부/검증상태 잔존 차단 + 슬러그 미리보기 fallback
|
||||
* (placeholder — data-testid 보강 후 활성화).
|
||||
*
|
||||
* @scenario form_mode=create, form_mode=edit
|
||||
* @effects create_form_clears_stale_attachments_via_form_attachments_binding,
|
||||
* create_form_clears_stale_title_slug_and_slug_check_state,
|
||||
* slug_hint_url_preview_falls_back_to_example_on_empty_slug
|
||||
*
|
||||
* e2e:allow 수정→등록 SPA 라우트 전환 시 이전 페이지의 제목/슬러그/첨부/검증상태가 등록 폼에
|
||||
* 잔존하던 회귀와, 빈 슬러그에서 미리보기가 "/page/{{slug}}" 처럼 미치환되던 회귀를
|
||||
* 레이아웃 렌더 테스트가 구조적으로 차단한다. 본 placeholder spec(test.describe.skip)은
|
||||
* data-testid 보강 후 활성화된다.
|
||||
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "수정→등록 SPA 전환 시 폼/첨부 잔존 차단")가
|
||||
* (1) 생성 모드 init_actions 가 _local.form 을 null→빈 기본값으로 초기화해 이전
|
||||
* id/title/slug/attachments 참조를 끊고,
|
||||
* (2) FileUploader initialFiles 가 _local.form.attachments 에 바인딩되며,
|
||||
* (3) slug_hint 가 빈 슬러그('')에서 || 'example' fallback 으로 {{slug}} 미치환을 차단함을
|
||||
* 구조적으로 회귀 차단한다. 브라우저 실 전환(라우트 A→B)의 리마운트 동작만 본 spec 이 확인한다.
|
||||
*
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
|
||||
* 1. 페이지 제목 입력에 data-testid="page-title-input"
|
||||
* 2. 슬러그 입력에 data-testid="page-slug-input"
|
||||
* 3. 슬러그 URL 미리보기 텍스트에 data-testid="page-slug-hint"
|
||||
* 4. 첨부 목록 컨테이너/아이템에 data-testid="page-attachment-item"
|
||||
* 5. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
|
||||
* 6. test.describe.skip → test.describe 변경
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 form_mode axis 와 1:1):
|
||||
* - edit→create 전환: 이전 제목/슬러그/첨부가 등록 폼에 비워진 상태로 진입
|
||||
* - create 진입 직후: 슬러그 미입력 상태에서 미리보기가 "/page/example"
|
||||
* - create 에서 슬러그 입력: 미리보기가 입력값으로 즉시 치환
|
||||
*/
|
||||
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('수정 폼에서 등록 폼으로 전환하면 이전 제목/슬러그/첨부가 잔존하지 않는다', async ({
|
||||
page,
|
||||
pageManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageManageToken);
|
||||
|
||||
// 1) 수정 폼 진입 — 기존 페이지의 제목/슬러그/첨부가 채워진 상태
|
||||
await page.goto(EDIT_URL);
|
||||
await expect(page.getByTestId('page-title-input')).not.toHaveValue('');
|
||||
|
||||
// 2) 등록 폼으로 SPA 전환 (같은 layout_name 재사용 경로)
|
||||
await page.getByTestId('page-nav-create').click();
|
||||
await expect(page).toHaveURL(new RegExp('/admin/pages/create$'));
|
||||
|
||||
// 3) 이전 값이 잔존하지 않아야 함
|
||||
await expect(page.getByTestId('page-title-input')).toHaveValue('');
|
||||
await expect(page.getByTestId('page-slug-input')).toHaveValue('');
|
||||
await expect(page.getByTestId('page-attachment-item')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('등록 폼 진입 직후 슬러그 미입력이면 미리보기가 /page/example 로 표시된다', async ({
|
||||
page,
|
||||
pageCreateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageCreateToken);
|
||||
await page.goto(CREATE_URL);
|
||||
|
||||
// 빈 슬러그에서 {{slug}} 미치환이 아니라 example fallback
|
||||
await expect(page.getByTestId('page-slug-hint')).toContainText('/page/example');
|
||||
await expect(page.getByTestId('page-slug-hint')).not.toContainText('{{slug}}');
|
||||
});
|
||||
|
||||
test('등록 폼에서 슬러그를 입력하면 미리보기가 입력값으로 즉시 치환된다', async ({
|
||||
page,
|
||||
pageCreateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageCreateToken);
|
||||
await page.goto(CREATE_URL);
|
||||
|
||||
await page.getByTestId('page-slug-input').fill('my-new-page');
|
||||
await expect(page.getByTestId('page-slug-hint')).toContainText('/page/my-new-page');
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 페이지 상세 버전 이력의 변경 필드명 다국어 라벨 렌더
|
||||
* (placeholder — data-testid 보강 후 활성화).
|
||||
*
|
||||
* @scenario page_change=update
|
||||
* @effects page_version_changed_fields_render_localized_labels
|
||||
*
|
||||
* e2e:allow 페이지 상세의 버전 이력 "변경 항목" 이 필드 키(title/slug/content 등) 원문 대신
|
||||
* 다국어 라벨로 표시되는 동작을 레이아웃 렌더 테스트가 구조적으로 커버한다. 본
|
||||
* placeholder spec(test.describe.skip)은 data-testid 보강 후 활성화된다.
|
||||
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "변경내역 필드명 다국어 매핑")가
|
||||
* changes_summary.changed_fields 를
|
||||
* $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 변경
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 page_change=update 와 1:1):
|
||||
* - update 후 상세 진입: 변경 항목이 필드 키 원문이 아닌 다국어 라벨로 표시
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
|
||||
|
||||
const DETAIL_URL = '/admin/pages/1';
|
||||
|
||||
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test('버전 이력의 변경 항목이 필드 키 원문 대신 다국어 라벨로 표시된다', async ({
|
||||
page,
|
||||
pageReadToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageReadToken);
|
||||
await page.goto(DETAIL_URL);
|
||||
|
||||
const changedFields = page.getByTestId('page-version-changed-fields').first();
|
||||
// 필드 키 원문(title/slug/content)이 그대로 노출되지 않아야 함
|
||||
await expect(changedFields).not.toHaveText(/\b(title|slug|content)\b/);
|
||||
// 다국어 라벨(예: 제목/주소/내용)로 렌더 — 활성화 시 구체 라벨 assert
|
||||
await expect(changedFields).not.toHaveText('');
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ class SeoPageCacheListenerTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* onPageChange 호출 시 페이지 상세 URL 캐시가 URL 패턴으로 무효화되는지 확인합니다 (이슈 #424 12-2).
|
||||
* onPageChange 호출 시 페이지 상세 URL 캐시가 URL 패턴으로 무효화되는지 확인합니다.
|
||||
*
|
||||
* 페이지 공개 URL은 단수형 /page/{slug} 이다 (PageSitemapContributor, SearchPagesListener 동일).
|
||||
* 레이아웃 태그 없이 put()으로 캐시된 항목은 invalidateByLayout('page/show')로는 지워지지 않으므로,
|
||||
|
||||
@@ -158,7 +158,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 시 업로드(조회) 순서대로 order가 1..N 재부여되는지 확인 (이슈 #424 6-2)
|
||||
* 연결 시 업로드(조회) 순서대로 order가 1..N 재부여되는지 확인
|
||||
*
|
||||
* 이커머스 ProductImageService::linkTempImages 와 동일하게, getByTempKey 가
|
||||
* order 순으로 반환한 순서대로 order 를 1,2,3 으로 재배치한다. (생성 모드의
|
||||
|
||||
@@ -218,7 +218,7 @@ class PageServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 발행 시 after_publish 훅이 페이지 수만큼 발화되는지 확인 (이슈 #424-19 회귀)
|
||||
* 일괄 발행 시 after_publish 훅이 페이지 수만큼 발화되는지 확인 (회귀)
|
||||
*
|
||||
* 일괄변경 경로가 after_publish 훅을 발화하지 않으면 활동로그가 기록되지 않는다.
|
||||
* 단일 발행(changePublishStatus)과 동일하게 페이지별(per-item) 발화를 보장한다.
|
||||
@@ -254,7 +254,7 @@ class PageServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미발행 시 after_publish 훅이 published=false 로 발화되는지 확인 (이슈 #424-19 회귀)
|
||||
* 일괄 미발행 시 after_publish 훅이 published=false 로 발화되는지 확인 (회귀)
|
||||
*/
|
||||
public function test_bulk_change_publish_status_fires_unpublish_hook_per_page(): void
|
||||
{
|
||||
@@ -277,7 +277,7 @@ class PageServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 처리 도중 한 건이라도 실패하면 전체 롤백되는지 확인 (이슈 #424-19 회귀)
|
||||
* 일괄 처리 도중 한 건이라도 실패하면 전체 롤백되는지 확인 (회귀)
|
||||
*
|
||||
* all-or-nothing: 존재하지 않는 id 가 섞이면 정상 페이지도 발행되지 않아야 한다.
|
||||
*/
|
||||
|
||||
+35
-8
@@ -1,9 +1,21 @@
|
||||
# audit:allow test-scenario-coverage reason: 본 매니페스트는 이슈 #424 (6-1/6-2/12-2) 회귀 매트릭스 SSoT 로 기록. 핵심 회귀 가드는 test_files 의 통과 테스트(백엔드 PHPUnit + 프론트 vitest)로 커버하며, 첨부 순서/URL 동작은 데이터 흐름 결함이라 단위 계층이 정확한 검증 위치 (브라우저 렌더 결함 아님).
|
||||
|
||||
feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저장 / 페이지 변경 시 SEO 캐시 무효화 (이슈 #424)
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# 본 매니페스트는 서로 독립적인 5개 페이지 검수 결함(첨부 URL 컨텍스트 6-1 / 첨부 순서 6-2 /
|
||||
# 수정→등록 폼 잔존 7 / SEO 캐시 무효화 12-2 / 버전 변경 필드 다국어 라벨 13)을 한 회귀
|
||||
# 매트릭스로 묶어 기록한다. 축(url_context/form_mode/upload_count/page_change/cached_entry)은
|
||||
# 결함별로 서로 무관해 full cross product(48케이스)에 "public × delete × multiple" 같은
|
||||
# 무의미 조합이 다수 포함되므로 docblock @scenario 전수 마킹이 부적합하다.
|
||||
# 실제 회귀 가드는 test_files 의 통과 테스트가 결함별로 담당한다:
|
||||
# 6-1 → PageControllerTest(Feature) / 6-2 → PageAttachmentServiceTest(Unit) +
|
||||
# admin-page-layouts.test.ts(레이아웃 렌더) / 7 → admin-page-layouts.test.ts /
|
||||
# 12-2 → SeoPageCacheListenerTest(Unit) / 13 → admin-page-layouts.test.ts.
|
||||
# E2E: 페이지 모듈에 Playwright 인프라를 이커머스와 동일 패턴으로 신설(config + page-auth/
|
||||
# page-seed fixture + playwright:seed-page stub)하고 spec 3건을 form_mode/upload_count/
|
||||
# page_change axis 와 1:1 매핑해 등록했으며, 대상 화면 data-testid 보강 전까지 skip placeholder
|
||||
# 로 운용한다(이커머스 product-seo-input-debounce 와 동일 운용).
|
||||
feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저장 / 페이지 변경 시 SEO 캐시 무효화 / 수정→등록 폼 잔존 차단 / 버전 변경 필드 다국어 라벨
|
||||
|
||||
description: |
|
||||
이슈 #424 의 3개 결함을 한 매트릭스로 묶어 회귀를 보장한다.
|
||||
페이지 검수 오류 회귀를 한 매트릭스로 묶어 보장한다.
|
||||
|
||||
6-1 (첨부 컨텍스트 URL):
|
||||
- 관리자 화면은 관리자 전용 다운로드/미리보기 URL, 공개 화면은 공개 URL 을 사용해야 한다.
|
||||
@@ -30,6 +42,12 @@ description: |
|
||||
- 이전 패턴은 복수형 */pages/{slug} 라 실제 URL 과 매칭되지 않아, 레이아웃 태그 없이
|
||||
put() 으로 캐시된 상세 캐시가 영구히 남았다 → */page/{slug} 로 정정.
|
||||
|
||||
13 (버전 변경 필드 다국어 라벨):
|
||||
- 페이지 상세 버전 이력의 "변경 항목" 이 필드 키(title/slug/content 등) 원문 대신 다국어
|
||||
라벨로 표시돼야 한다.
|
||||
- changes_summary.changed_fields 를
|
||||
$t('sirsoft-page.admin.page.detail.versions.field_labels.<field>') 로 매핑.
|
||||
|
||||
axes:
|
||||
url_context: [public, admin]
|
||||
form_mode: [create, edit]
|
||||
@@ -49,17 +67,26 @@ effects:
|
||||
- page_change_invalidates_url_only_cached_detail_without_layout_tag
|
||||
- page_change_invalidates_page_show_layout_cache
|
||||
- page_change_invalidates_home_and_search_and_sitemap_cache
|
||||
- page_version_changed_fields_render_localized_labels
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
|
||||
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts
|
||||
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-reset-on-route-switch.spec.ts
|
||||
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-attachment-order.spec.ts
|
||||
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-version-changed-fields-i18n.spec.ts
|
||||
|
||||
notes: |
|
||||
6-2 는 이커머스 상품 이미지(FileUploader + temp_key 연결 + reorder API)와 동일한 검증된
|
||||
패턴으로 정렬했다. 생성 모드 업로드 순서 연결은 PageAttachmentServiceTest, 수정 모드 reorder 와
|
||||
잔존 버그(라우트 전환 시 key 리마운트)는 admin_page_form 레이아웃 + 브라우저 검수로 확인.
|
||||
6-1 은 백엔드 Feature 테스트, 12-2 는 리스너 unit 테스트로 커버.
|
||||
E2E 면제 사유: 6-2 데이터 흐름은 단위/Feature 계층이 정확한 검증 위치이고, 페이지 모듈에
|
||||
Playwright 인프라(토큰/스펙/베이스) 미구축 상태로 spec 1건을 위한 인프라 신설은 본 이슈 범위를 초과한다.
|
||||
잔존 버그(라우트 전환 시 key 리마운트)는 admin_page_form 레이아웃 렌더 테스트로 확인.
|
||||
6-1 은 백엔드 Feature 테스트, 12-2 는 리스너 unit 테스트, 13 은 레이아웃 렌더 테스트로 커버.
|
||||
|
||||
E2E: 페이지 모듈에 Playwright 인프라(playwright.config.ts + page-auth/page-seed fixture +
|
||||
playwright:seed-page stub 커맨드)를 이커머스와 동일 패턴으로 신설했다. spec 3건은
|
||||
form_mode / upload_count / page_change axis 와 1:1 매핑되며, 대상 화면에 data-testid 가
|
||||
아직 없어 test.describe.skip placeholder 로 등록(각 spec docblock 에 활성화 절차 명시).
|
||||
실제 회귀 가드는 위 단위/Feature/레이아웃 렌더 테스트가 담당하고, spec 은 data-testid 보강 +
|
||||
seed 로직 구현 후 활성화된다 (이커머스 product-seo-input-debounce 등과 동일 운용).
|
||||
Reference in new issue
Block a user