엔진 - dot notation setState(target:"_local.xxx")가 canonical _global._local 을 동기화하지 않아, 화면에는 선택 표시가 되는데 확장 커스텀 핸들러의 getLocal 에는 값이 보이지 않던 비대칭을 해소했다(engine-v1.58.2). 예외도 경고도 없이 값만 비어 발견이 늦다. 동기화 기준을 전체 스냅샷이 아니라 live _global._local + 변경 키로 잡아, 부분 상태가 base 가 되어 다른 키를 잃거나 앞선 setLocal 을 되돌리는 기존 회귀 경로를 피했다. 토스페이먼츠 - 간편결제 자체창 직행 지정을 top-level easyPay 로 넘겨 SDK 가 통째로 무시하던 것을 card.flowMode DIRECT + card.easyPay 정본 계약으로 교정했다. 이커머스 - defaults 에서 보충된 통화가 옛 is_default 를 들여와 기본 통화가 둘이 되면서 환율이 1.0 으로 박제돼 PG 청구액이 1/100 로 나갔다. 병합 직후 재동기화하고, 표시 계층의 기본 통화 해석을 default_currency 우선으로 통일해 환산 계층과 일치시켰다. - 공급 확장이 더 이상 제공하지 않는 고아 결제수단이 주문서에 노출되던 것을 공개 API 단일 지점에서 차단했다. 관리자 응답은 운영자가 확인·삭제해야 하므로 그대로 둔다. - 관리자 주문설정에서 Select 에 준 폭 클래스가 컴포넌트 기본 w-full 에 밀려 죽는 구조라, 폭 책임을 셀렉트에서 열 컨테이너로 옮겼다. - 고아 행에서 마일리지 차감 시점 열만 가드가 빠져 노출되던 사전 결함을 함께 고쳤다. 열을 열거하는 대신 조상 체인의 가드 유무를 스캔해, 이후 추가되는 열도 자동으로 검사 대상이 되도록 회귀 테스트를 구성했다. 개발 도구 - dev-dashboard 인스톨러 초기화에 환경설정/첨부파일 삭제 토글을 추가했다. 컨테이너 디렉토리를 보존해 재생성 시 소유권이 바뀌지 않게 하고, 두 옵션이 같은 경로를 다뤄도 서로 간섭하지 않도록 분리했다. 규정 - 저장값과 확장 훅 카탈로그가 병합되는 설정에서 공개 응답이 카탈로그 소속까지 판정해야 한다는 규칙을 문서화하고, 정적 판정이 불가한 근거와 함께 coverage 에 등록했다.
125 lines
5.8 KiB
TypeScript
125 lines
5.8 KiB
TypeScript
/**
|
|
* E2E: `setState target: "_local.xxx"` 로 기록한 값을 커스텀 핸들러가 읽을 수 있는지 (배포 번들 기준)
|
|
*
|
|
* 배경: `_local` 은 저장소가 둘이다 — React `localDynamicState`(A)와 `_global._local`(B).
|
|
* dot notation 분기는 A 만 갱신하고 B 를 갱신하지 않았다. 모듈·플러그인 커스텀 핸들러가 상태를
|
|
* 읽는 공개 통로인 `G7Core.state.getLocal()` 은 B 를 읽으므로, dot notation 으로 기록된 값은
|
|
* 핸들러에게 `undefined` 로 보였다.
|
|
*
|
|
* 화면은 정상으로 보인다 — 선택 표시는 A 만으로 그려지기 때문이다. 예외도 콘솔 경고도 남지 않아
|
|
* 화면 확인만으로는 드러나지 않는다. 실제 발현: 주문서형 결제에서 간편결제(네이버페이 등)를
|
|
* 고르고 결제하면 PG 플러그인이 선택값을 못 읽어 간편결제 자체창 대신 통합결제창이 열렸다.
|
|
*
|
|
* 단위 테스트가 소스를 잠그지만, 실제 사용자가 실행하는 것은 빌드된 번들이므로 브라우저에서
|
|
* 배포 번들의 액션 파이프를 직접 태워 결과를 잠근다. PG 샌드박스는 필요 없다 — 잠그는 계약은
|
|
* "dot notation 기록이 `getLocal()` 에 도달한다" 하나이고, 화면과 무관하게 성립해야 한다.
|
|
*
|
|
* @scenario setstate_dot_path_local_store_sync
|
|
* @effects dot_path_setstate_reaches_getlocal,
|
|
* dot_path_setstate_preserves_other_local_keys,
|
|
* dot_path_setstate_accumulates_across_writes
|
|
*/
|
|
import { test, expect } from '../fixtures/auth';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/** 엔진이 부팅된 아무 공개 화면. dot notation 계약은 화면과 무관하게 성립해야 한다. */
|
|
const ANY_ENGINE_PAGE = '/';
|
|
|
|
/** 테스트 전용 키 — 어떤 레이아웃도 쓰지 않는 이름이라 기존 상태와 충돌하지 않는다. */
|
|
const PROBE_KEY = '__e2eDotPathProbe';
|
|
|
|
/**
|
|
* 템플릿 렌더가 끝날 때까지 기다린다.
|
|
*
|
|
* `G7Core.dispatch` 는 TemplateApp 부팅 **중간**에 이미 존재하지만, 액션 디스패처에
|
|
* `setGlobalState` 가 주입되는 것은 그보다 뒤다. dispatch 가능 시점만 보고 진행하면
|
|
* 아직 주입 전이라 canonical 저장소 동기화가 통째로 건너뛰어진다(실측으로 이 함정에 빠졌다).
|
|
* 렌더 완료를 게이트로 삼아 부팅이 끝난 뒤에 계약을 검증한다.
|
|
*/
|
|
async function waitForEngineBoot(page: Page): Promise<void> {
|
|
await page.waitForFunction(() => typeof (window as any).G7Core?.dispatch === 'function');
|
|
await page.locator('footer').first().waitFor({ state: 'attached', timeout: 30_000 });
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
test.describe('setState dot notation 경로의 _local 저장소 동기화', () => {
|
|
test('dot notation 으로 기록한 값이 getLocal() 로 읽힌다', async ({ page }) => {
|
|
await page.goto(ANY_ENGINE_PAGE);
|
|
await waitForEngineBoot(page);
|
|
|
|
const result = await page.evaluate(async (key) => {
|
|
const G7Core = (window as any).G7Core;
|
|
const before = G7Core.state.getLocal()?.[key] ?? null;
|
|
|
|
await G7Core.dispatch({
|
|
handler: 'setState',
|
|
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
|
});
|
|
|
|
return {
|
|
before,
|
|
getLocal: G7Core.state.getLocal()?.[key] ?? null,
|
|
canonical: (window as any).__templateApp?.getGlobalState?.()?._local?.[key] ?? null,
|
|
};
|
|
}, PROBE_KEY);
|
|
|
|
expect(result.before, '프로브 키가 이미 오염돼 있으면 이 테스트는 의미가 없다').toBeNull();
|
|
// 결함이 있으면 여기서 null 이 나온다 — 커스텀 핸들러가 보던 그 값이다
|
|
expect(result.getLocal).toBe('toss_naverpay');
|
|
expect(result.canonical).toBe('toss_naverpay');
|
|
});
|
|
|
|
test('동기화가 canonical 저장소의 다른 키를 지우지 않는다', async ({ page }) => {
|
|
await page.goto(ANY_ENGINE_PAGE);
|
|
await waitForEngineBoot(page);
|
|
|
|
const result = await page.evaluate(async (key) => {
|
|
const G7Core = (window as any).G7Core;
|
|
|
|
// 커스텀 핸들러가 setLocal 로 canonical 저장소에 먼저 기록한 상황을 만든다
|
|
G7Core.state.setLocal({ [`${key}Keep`]: 'must-survive' });
|
|
|
|
await G7Core.dispatch({
|
|
handler: 'setState',
|
|
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
|
});
|
|
|
|
const canonical = (window as any).__templateApp?.getGlobalState?.()?._local ?? {};
|
|
return { written: canonical[key] ?? null, kept: canonical[`${key}Keep`] ?? null };
|
|
}, PROBE_KEY);
|
|
|
|
expect(result.written).toBe('toss_naverpay');
|
|
// 부분 상태 스냅샷으로 canonical 을 통째 교체하면 여기서 사라진다
|
|
expect(result.kept).toBe('must-survive');
|
|
});
|
|
|
|
test('연속 기록이 서로를 지우지 않고 누적된다', async ({ page }) => {
|
|
await page.goto(ANY_ENGINE_PAGE);
|
|
await waitForEngineBoot(page);
|
|
|
|
const canonical = await page.evaluate(async (key) => {
|
|
const G7Core = (window as any).G7Core;
|
|
|
|
// 결제수단을 골랐다가 다른 것으로 바꾸는 흐름 — 마지막 값이 남고 동반 키는 유지돼야 한다
|
|
await G7Core.dispatch({
|
|
handler: 'setState',
|
|
params: { target: `_local.${key}`, value: 'toss_tosspay' },
|
|
});
|
|
await G7Core.dispatch({
|
|
handler: 'setState',
|
|
params: { target: `_local.${key}Sibling`, value: 'sibling-value' },
|
|
});
|
|
await G7Core.dispatch({
|
|
handler: 'setState',
|
|
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
|
});
|
|
|
|
const local = (window as any).__templateApp?.getGlobalState?.()?._local ?? {};
|
|
return { last: local[key] ?? null, sibling: local[`${key}Sibling`] ?? null };
|
|
}, PROBE_KEY);
|
|
|
|
expect(canonical.last).toBe('toss_naverpay');
|
|
expect(canonical.sibling).toBe('sibling-value');
|
|
});
|
|
});
|