fix(core): 다중 progressive 데이터소스의 initLocal 유실 수정
_localInit 은 단일 슬롯이고 updateTemplateData 가 얕은 스프레드로 교체했다. 소비부(DynamicRenderer useEffect)는 React commit 이후에 실행되므로, initLocal 을 가진 progressive 소스가 둘 이상이면 두 payload 가 같은 commit 사이에 도착할 때 먼저 온 것이 한 번도 관측되지 않고 사라졌다. 무조건 누적 병합은 refetchDataSource 시 소비가 끝난 payload 를 재적용해 사용자 폼 편집을 되돌린다. 그래서 아직 관측되지 않은 슬롯만 병합하고, 관측된 슬롯은 종전대로 교체한다. 소비 여부는 소비부가 기록한 슬롯 참조로 판정해 해시 계산식을 생산부에 복제하지 않는다. 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 비운다. 검증: CPU 6배 스로틀로 경합 창을 강제로 열어 수정 전 0/8 → 수정 후 8/8. 동반: template-engine.test.ts 의 vi.mock 이 공유 런타임 export 를 빠뜨려 스위트가 로드되지 않던 것과, 코어 spec 2개가 없는 fixture 를 import 해 playwright 수집이 실패하던 것을 함께 복구했다.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
### Fixed
|
||||
|
||||
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
|
||||
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
|
||||
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.2] - 2026-07-08
|
||||
|
||||
@@ -185,6 +185,40 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
|
||||
- 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션
|
||||
- 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~50줄)
|
||||
|
||||
### `_localInit` 은 단일 슬롯이 아니다 (engine-v1.52.2+)
|
||||
|
||||
데이터소스의 `initLocal` 이 만든 초기화 payload 는 `dataContext._localInit` 한 키로 전달된다.
|
||||
생산부는 `updateTemplateData`(3개 write site), 소비부는 `DynamicRenderer` 의 `_localInit` useEffect 다.
|
||||
|
||||
소비는 React commit **이후**에 일어난다. progressive 데이터소스는 응답이 오는 대로 각자 독립적으로
|
||||
`updateTemplateData({ _localInit })` 를 호출하므로, `initLocal` 을 가진 progressive 소스가 둘 이상이면
|
||||
두 호출이 같은 commit 사이에 들어올 수 있다. 이때 슬롯을 교체하면 먼저 도착한 payload 가
|
||||
한 번도 관측되지 않고 사라진다.
|
||||
|
||||
`_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자는
|
||||
[`localInitSlot.ts`](../../resources/js/core/template-engine/localInitSlot.ts) 다.
|
||||
|
||||
```text
|
||||
❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체
|
||||
❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합
|
||||
(소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다)
|
||||
❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제
|
||||
(생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다)
|
||||
❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음
|
||||
(_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다)
|
||||
```
|
||||
|
||||
```text
|
||||
✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot()
|
||||
✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출
|
||||
✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking()
|
||||
✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지)
|
||||
✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다
|
||||
```
|
||||
|
||||
> 이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야
|
||||
> 경합 창이 결정적으로 열린다.
|
||||
|
||||
### 라이프사이클 (SPA 네비게이션)
|
||||
|
||||
SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
|
||||
|
||||
+15
-15
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ import { createLogger, Logger } from './utils/Logger';
|
||||
import { webSocketManager } from './websocket/WebSocketManager';
|
||||
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
|
||||
import { SystemBannerManager } from './template-engine/SystemBannerManager';
|
||||
import { resetLocalInitTracking } from './template-engine/localInitSlot';
|
||||
/**
|
||||
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
||||
*/
|
||||
@@ -1146,6 +1147,11 @@ export class TemplateApp {
|
||||
// 다른 레이아웃으로 전환 → _local 완전 초기화 (이전 레이아웃 잔존값 제거)
|
||||
logger.log('_local reset due to layout change:', { from: this.currentLayoutName, to: newLayoutName });
|
||||
this.globalState._local = {};
|
||||
|
||||
// @since engine-v1.52.2: _localInit 추적 레지스트리도 함께 초기화.
|
||||
// _local 을 비웠는데 추적 해시가 남아 있으면, 새 레이아웃의 _localInit payload 가
|
||||
// 이전 레이아웃과 우연히 동일할 때 "이미 적용됨"으로 오판되어 건너뛴다.
|
||||
resetLocalInitTracking();
|
||||
}
|
||||
|
||||
this.currentLayoutName = newLayoutName;
|
||||
|
||||
@@ -14,6 +14,7 @@ import TemplateEngine, {
|
||||
getState,
|
||||
} from '../template-engine';
|
||||
import { ComponentRegistry } from '../template-engine/ComponentRegistry';
|
||||
import { markLocalInitConsumed, resetLocalInitTracking } from '../template-engine/localInitSlot';
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
/**
|
||||
@@ -45,12 +46,19 @@ vi.mock('../template-engine/ComponentRegistry', () => {
|
||||
});
|
||||
|
||||
// DataBindingEngine mock
|
||||
vi.mock('../template-engine/DataBindingEngine', () => ({
|
||||
DataBindingEngine: vi.fn(function(this: any) {
|
||||
// G7CoreGlobals.initCoreRuntimeExports가 싱글톤 `dataBindingEngine`도 참조하므로 함께 노출
|
||||
vi.mock('../template-engine/DataBindingEngine', () => {
|
||||
const DataBindingEngine = vi.fn(function(this: any) {
|
||||
this.bind = vi.fn();
|
||||
this.unbind = vi.fn();
|
||||
}),
|
||||
}));
|
||||
this.invalidateCacheByKeys = vi.fn();
|
||||
});
|
||||
|
||||
return {
|
||||
DataBindingEngine,
|
||||
dataBindingEngine: new (DataBindingEngine as any)(),
|
||||
};
|
||||
});
|
||||
|
||||
// TranslationEngine mock
|
||||
vi.mock('../template-engine/TranslationEngine', () => {
|
||||
@@ -97,10 +105,13 @@ vi.mock('../template-engine/ResponsiveManager', () => ({
|
||||
getMatchingKey: vi.fn(() => null),
|
||||
parseRange: vi.fn(() => null),
|
||||
},
|
||||
BREAKPOINT_PRESETS: {},
|
||||
}));
|
||||
|
||||
// ResponsiveContext mock
|
||||
// G7CoreGlobals.initCoreRuntimeExports가 Context 객체 자체도 참조하므로 함께 노출
|
||||
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||
ResponsiveContext: {},
|
||||
ResponsiveProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useResponsive: vi.fn(() => ({
|
||||
width: 1024,
|
||||
@@ -374,6 +385,8 @@ describe('TemplateEngine - updateTemplateData()', () => {
|
||||
beforeEach(async () => {
|
||||
destroyTemplate();
|
||||
vi.clearAllMocks();
|
||||
// DynamicRenderer가 모킹되어 _localInit 슬롯을 소비하지 않으므로 테스트 간 격리 필요
|
||||
resetLocalInitTracking();
|
||||
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
|
||||
@@ -456,6 +469,53 @@ describe('TemplateEngine - updateTemplateData()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 회귀
|
||||
*
|
||||
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면,
|
||||
* React commit 전에 두 번의 updateTemplateData가 연달아 들어올 때
|
||||
* 나중 payload가 _localInit 슬롯을 통째로 교체하여 앞선 소스의 초기값이 유실됨.
|
||||
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯은 교체하지 않고 누적 병합.
|
||||
*
|
||||
* @see troubleshooting-state-global.md
|
||||
*/
|
||||
it('소비되지 않은 _localInit 슬롯은 후속 payload와 누적 병합되어야 함', () => {
|
||||
// settings 소스 응답 (refetchOnMount → _forceLocalInit 보유)
|
||||
updateTemplateData({
|
||||
settings: { shipping: {} },
|
||||
_localInit: { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 },
|
||||
});
|
||||
|
||||
// notification 소스 응답 (같은 commit 이전에 도착)
|
||||
updateTemplateData({
|
||||
notificationDefinitions: { data: [] },
|
||||
_localInit: { notificationDefinitionCurrentPage: 1 },
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
// 먼저 도착한 settings의 form이 살아있어야 함
|
||||
expect(state.currentDataContext._localInit.form).toEqual({ name: 'shipping' });
|
||||
// 나중에 도착한 notification 값도 함께 있어야 함
|
||||
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(1);
|
||||
// refetchOnMount 타임스탬프가 보존되어야 함
|
||||
expect(state.currentDataContext._localInit._forceLocalInit).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it('이미 소비된 _localInit 슬롯은 교체되어 stale 재적용이 없어야 함', () => {
|
||||
updateTemplateData({ _localInit: { form: { name: 'shipping' } } });
|
||||
|
||||
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect가 하는 일)
|
||||
markLocalInitConsumed(getState().currentDataContext._localInit);
|
||||
|
||||
// 사용자가 화면을 쓰는 도중 다른 소스를 refetch
|
||||
updateTemplateData({ _localInit: { notificationDefinitionCurrentPage: 2 } });
|
||||
|
||||
const state = getState();
|
||||
// 소비가 끝난 form은 재적용 대상이 아니어야 함 (폼 편집 결과 되돌림 방지)
|
||||
expect(state.currentDataContext._localInit.form).toBeUndefined();
|
||||
expect(state.currentDataContext._localInit.notificationDefinitionCurrentPage).toBe(2);
|
||||
});
|
||||
|
||||
it('sync 옵션으로 업데이트해도 정상 작동해야 함', () => {
|
||||
updateTemplateData({ c: 5 }, { sync: true });
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DataSourceManager } from './template-engine/DataSourceManager';
|
||||
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
|
||||
import { ParentContextProvider } from './template-engine/ParentContextProvider';
|
||||
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
|
||||
import { mergeLocalInitSlot } from './template-engine/localInitSlot';
|
||||
import { ErrorDisplay } from './template-engine/ErrorDisplay';
|
||||
import { TemplateApp, initTemplateApp } from './TemplateApp';
|
||||
import { createLogger } from './utils/Logger';
|
||||
@@ -749,11 +750,20 @@ function updateTemplateData(data: Record<string, any>, options?: UpdateOptions):
|
||||
...(data._global || {}),
|
||||
};
|
||||
|
||||
// _localInit도 얕은 스프레드로 교체하면 안 됨 (engine-v1.52.2)
|
||||
// progressive 데이터소스가 둘 이상이면 각자 독립적으로 updateTemplateData를 호출하는데,
|
||||
// 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
|
||||
// 두 호출이 같은 commit 사이에 들어오면 나중 payload가 슬롯을 통째로 교체하여
|
||||
// 먼저 도착한 소스의 initLocal이 한 번도 관측되지 않고 사라진다.
|
||||
// → 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합한다. 상세: localInitSlot.ts
|
||||
const mergedLocalInit = mergeLocalInitSlot(state.currentDataContext._localInit, data._localInit);
|
||||
|
||||
state.currentDataContext = {
|
||||
...globalVariables, // 전역 변수 (낮은 우선순위)
|
||||
...state.currentDataContext, // 기존 데이터
|
||||
...data, // 새 데이터 (높은 우선순위)
|
||||
_global: mergedGlobalState, // _global은 명시적으로 깊은 병합된 값 사용
|
||||
_localInit: mergedLocalInit, // _localInit은 소비 전이면 누적 병합된 값 사용
|
||||
};
|
||||
|
||||
// _local 및 _computed는 _global의 값을 canonical source로 동기화
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.52.2] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 다중 progressive 데이터소스의 `_localInit` 상호 덮어쓰기
|
||||
|
||||
- `template-engine.ts::updateTemplateData()` — `_localInit` 을 얕은 스프레드(`...data`)로 교체하던 것을 **소비 여부를 가드로 둔 누적 병합**으로 바꿨다. `_localInit` 소비부(`DynamicRenderer` 의 useEffect)는 React commit 이후에 실행되는데, `initLocal` 을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상이면 각 소스가 응답 순서대로 독립적으로 `updateTemplateData({ _localInit })` 를 호출한다. 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체해, 먼저 도착한 소스의 `initLocal` 이 **한 번도 관측되지 않은 채** 사라졌다. `_global` 은 바로 윗줄에서 깊은 병합을 받고 있었으나 `_localInit` 에는 그 처리가 없었다.
|
||||
- `localInitSlot.ts`(신규) — `_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자. 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합하고, 이미 관측된 슬롯은 종전대로 교체한다. 그래야 사용자가 화면을 쓰는 도중 `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어 폼 편집 결과를 되돌리지 않는다. `_forceLocalInit` 은 한쪽에만 있으면 보존하고 양쪽에 있으면 최신 타임스탬프를 취한다.
|
||||
- `DynamicRenderer.tsx` — `_localInit` 관측 시 그 슬롯 참조를 `markLocalInitConsumed()` 로 기록한다. 해시 계산식을 생산부에 복제하지 않으므로 생산·소비 양쪽의 판정 기준이 어긋날 수 없다. 적용(`shouldApply`)/건너뜀(동일 해시) 여부와 무관하게 기록한다 — 건너뛴 payload 도 이미 `_local` 에 반영되어 있으므로 소비된 것이다. A(`setLocalDynamicState`)/B(`_globalSetState`) 이중 저장소 갱신 대칭은 그대로 유지한다.
|
||||
- `TemplateApp.ts` — SPA 레이아웃 전환으로 `_local` 을 리셋할 때 `__g7LocalInitTracking` 도 함께 초기화한다(`resetLocalInitTracking()`). 종전에는 리셋 지점이 아예 없어, 새 레이아웃의 `_localInit` payload 가 이전 레이아웃과 우연히 같으면 "이미 적용됨" 으로 오판되어 건너뛰었다.
|
||||
- 부수 수정 — `init_actions` 안의 `refetchDataSource` 가 React commit 이전에 `updateTemplateData` 를 호출하는 경우(`pendingDataUpdates` 큐 경유)에도 blocking 소스의 `initLocal` 이 조용히 유실되던 잠재 결함이 함께 닫힌다.
|
||||
|
||||
> 사용자 영향: `initLocal` 을 가진 데이터소스가 둘 이상인 화면에 SPA 네비게이션으로 진입할 때, 먼저 도착한 데이터소스의 초기값이 간헐적으로 비어 보이던 문제가 해소된다.
|
||||
|
||||
## [engine-v1.52.1] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -29,6 +29,7 @@ import { hasPipes } from './PipeRegistry';
|
||||
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
|
||||
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
|
||||
import { useTransitionState } from './TransitionContext';
|
||||
import { getLocalInitTracking, markLocalInitConsumed } from './localInitSlot';
|
||||
import { useResponsive } from './ResponsiveContext';
|
||||
import { createLogger } from '../utils/Logger';
|
||||
import { shallowObjectEqual } from '../hooks/useControllableState';
|
||||
@@ -1318,11 +1319,7 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
||||
// - extends 레이아웃에서 여러 컴포넌트가 동일한 _localInit을 받을 수 있음
|
||||
// - 컴포넌트 ID 기반 추적은 각 컴포넌트가 독립적으로 처리하여 중복 적용됨
|
||||
// - 데이터 해시만으로 추적하여, 같은 데이터는 어떤 컴포넌트에서든 한 번만 처리
|
||||
const globalTrackingKey = '__g7LocalInitTracking';
|
||||
if (!(window as any)[globalTrackingKey]) {
|
||||
(window as any)[globalTrackingKey] = { hash: '', timestamp: undefined };
|
||||
}
|
||||
const tracking = (window as any)[globalTrackingKey] as { hash: string; timestamp?: number };
|
||||
const tracking = getLocalInitTracking();
|
||||
|
||||
// 추적 키는 데이터 해시 + 타임스탬프 (컴포넌트 ID 제외)
|
||||
// refetchOnMount 시 _forceLocalInit 타임스탬프가 변경되면 재적용됨
|
||||
@@ -1398,6 +1395,11 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
|
||||
// hash tracking에 의해 shouldApply가 false(skip)된 경우에도 갱신하여
|
||||
// extendedDataContext useMemo가 정상 병합(deepMergeState)을 재개하도록 함
|
||||
lastProcessedInitRef.current = dataContext._localInit;
|
||||
|
||||
// @since engine-v1.52.2: 이 슬롯을 관측했음을 생산부(updateTemplateData)에 알림.
|
||||
// 생산부는 아직 관측되지 않은 슬롯만 누적 병합하고, 관측된 슬롯은 교체한다.
|
||||
// skip(동일 해시)된 경우에도 그 payload는 이미 _local에 반영되어 있으므로 소비된 것이다.
|
||||
markLocalInitConsumed(dataContext._localInit);
|
||||
}
|
||||
}, [dataContext._localInit, parentDataContext, bindingEngine]);
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ActionDispatcher } from '../ActionDispatcher';
|
||||
import {
|
||||
getLocalInitTracking,
|
||||
isLocalInitConsumed,
|
||||
markLocalInitConsumed,
|
||||
mergeLocalInitSlot,
|
||||
resetLocalInitTracking,
|
||||
} from '../localInitSlot';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
// AuthManager mock
|
||||
@@ -580,4 +587,91 @@ describe('트러블슈팅 회귀 테스트 - 데이터소스 업데이트 및
|
||||
expect(keyNoForce).toContain('no-force');
|
||||
});
|
||||
});
|
||||
|
||||
describe('[사례 6] 다중 progressive 데이터소스의 _localInit 상호 덮어쓰기 (engine-v1.52.2)', () => {
|
||||
/**
|
||||
* 증상: initLocal을 가진 progressive 데이터소스가 한 레이아웃에 둘 이상일 때,
|
||||
* SPA 네비게이션으로 진입하면 먼저 도착한 소스의 initLocal이 간헐적으로 유실됨
|
||||
* (배송설정 탭: 배송가능 국가 표가 비고 "No shipping countries registered.")
|
||||
* 원인: progressive 소스는 응답이 오는 대로 각자 updateTemplateData({ _localInit })를 호출하는데,
|
||||
* 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
|
||||
* 두 호출이 같은 commit 사이에 들어오면 나중 payload가 _localInit 슬롯을 통째로 교체하여
|
||||
* 먼저 도착한 소스의 payload는 한 번도 관측되지 않고 사라진다.
|
||||
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯만 누적 병합.
|
||||
* 이미 소비된 슬롯은 교체 (refetch 시 stale 재적용으로 폼 편집이 되돌아가는 것 방지).
|
||||
*
|
||||
* @see resources/js/core/template-engine/localInitSlot.ts
|
||||
* @see resources/js/core/template-engine.ts - updateTemplateData
|
||||
*/
|
||||
beforeEach(() => {
|
||||
resetLocalInitTracking();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLocalInitTracking();
|
||||
});
|
||||
|
||||
it('소비 전 슬롯은 후속 payload와 누적 병합되어 양쪽 키가 모두 남아야 함', () => {
|
||||
const settingsInit = { form: { name: 'shipping' } };
|
||||
const notificationInit = { notificationDefinitionCurrentPage: 1 };
|
||||
|
||||
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
|
||||
|
||||
expect(merged.form).toEqual({ name: 'shipping' });
|
||||
expect(merged.notificationDefinitionCurrentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('한쪽에만 있는 _forceLocalInit 타임스탬프가 보존되어야 함', () => {
|
||||
// settings만 refetchOnMount: true → _forceLocalInit 보유
|
||||
const settingsInit = { form: { name: 'shipping' }, _forceLocalInit: 1700000000000 };
|
||||
const notificationInit = { notificationDefinitionCurrentPage: 1 };
|
||||
|
||||
const merged = mergeLocalInitSlot(settingsInit, notificationInit) as Record<string, any>;
|
||||
|
||||
expect(merged._forceLocalInit).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it('양쪽 모두 _forceLocalInit을 가지면 최신 타임스탬프를 취해야 함', () => {
|
||||
const older = { a: 1, _forceLocalInit: 1700000000000 };
|
||||
const newer = { b: 2, _forceLocalInit: 1700000001000 };
|
||||
|
||||
const merged = mergeLocalInitSlot(older, newer) as Record<string, any>;
|
||||
|
||||
expect(merged._forceLocalInit).toBe(1700000001000);
|
||||
});
|
||||
|
||||
it('이미 소비된 슬롯은 병합하지 않고 교체해야 함 (stale 재적용 방지)', () => {
|
||||
const settingsInit = { form: { name: 'shipping' } };
|
||||
|
||||
// 렌더러가 슬롯을 관측 (DynamicRenderer의 _localInit useEffect)
|
||||
markLocalInitConsumed(settingsInit);
|
||||
|
||||
// 이후 refetchDataSource가 다른 소스의 initLocal을 실어 옴
|
||||
const refetchInit = { notificationDefinitionCurrentPage: 2 };
|
||||
const merged = mergeLocalInitSlot(settingsInit, refetchInit) as Record<string, any>;
|
||||
|
||||
// 소비가 끝난 form은 재적용 대상이 아님 → 사용자 폼 편집이 되돌아가지 않음
|
||||
expect(merged.form).toBeUndefined();
|
||||
expect(merged.notificationDefinitionCurrentPage).toBe(2);
|
||||
});
|
||||
|
||||
it('새 payload가 없으면 기존 슬롯 참조를 그대로 유지해야 함 (소비부 effect 재발화 방지)', () => {
|
||||
const slot = { form: { name: 'shipping' } };
|
||||
|
||||
// updateTemplateData({ someData: 1 }) 처럼 _localInit이 없는 업데이트
|
||||
expect(mergeLocalInitSlot(slot, undefined)).toBe(slot);
|
||||
});
|
||||
|
||||
it('레이아웃 전환 시 추적 레지스트리가 초기화되어야 함', () => {
|
||||
const slot = { form: { name: 'shipping' } };
|
||||
markLocalInitConsumed(slot);
|
||||
expect(isLocalInitConsumed(slot)).toBe(true);
|
||||
|
||||
// TemplateApp.loadRoute의 레이아웃 전환 감지 → _local 리셋과 함께 호출
|
||||
resetLocalInitTracking();
|
||||
|
||||
expect(isLocalInitConsumed(slot)).toBe(false);
|
||||
expect(getLocalInitTracking().hash).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* `_localInit` 슬롯 관리 (생산부 ↔ 소비부 공용)
|
||||
*
|
||||
* `_localInit` 은 데이터소스의 `initLocal` 옵션이 만든 로컬 상태 초기화 payload 를
|
||||
* 렌더 컨텍스트에 실어 나르는 단일 키다. 생산부(TemplateApp → updateTemplateData)와
|
||||
* 소비부(DynamicRenderer 의 useEffect)가 이 키 하나를 공유한다.
|
||||
*
|
||||
* 문제: 소비는 React commit 이후(useEffect)에 일어난다. progressive 데이터소스가
|
||||
* 둘 이상이면 각자 독립적으로 `updateTemplateData({ _localInit })` 를 호출하는데,
|
||||
* 두 호출이 같은 commit 사이에 들어오면 나중 payload 가 슬롯을 통째로 교체하여
|
||||
* 먼저 도착한 소스의 초기값이 **한 번도 관측되지 않은 채** 사라진다.
|
||||
*
|
||||
* 해결: 아직 어떤 렌더러도 관측하지 않은(unconsumed) 슬롯에 한해 누적 병합한다.
|
||||
* 이미 소비된 슬롯은 지금까지처럼 교체한다 — 그래야 사용자가 화면을 쓰는 도중
|
||||
* `refetchDataSource` 가 발생해도 소비가 끝난 과거 payload 가 재적용되어
|
||||
* 폼 편집 결과를 되돌리는 일이 없다.
|
||||
*
|
||||
* 소비 여부는 소비부(DynamicRenderer)가 관측한 슬롯의 **참조**를 기록해 판정한다.
|
||||
* 해시 계산식을 생산부에 복제하지 않으므로 두 곳의 판정 기준이 어긋날 수 없다.
|
||||
*
|
||||
* @since engine-v1.52.2
|
||||
*/
|
||||
|
||||
/** `_localInit` 전역 추적 레지스트리 */
|
||||
export interface LocalInitTracking {
|
||||
/** 마지막으로 적용된 payload 의 추적 키 (`해시:_forceLocalInit`) */
|
||||
hash: string;
|
||||
/** 마지막으로 적용된 `_forceLocalInit` 타임스탬프 */
|
||||
timestamp?: number;
|
||||
/**
|
||||
* 마지막으로 렌더러가 관측한 슬롯 객체의 참조.
|
||||
* 생산부가 이 참조로 "소비 완료" 여부를 판정한다.
|
||||
*/
|
||||
consumed?: unknown;
|
||||
}
|
||||
|
||||
/** window 전역 키 (기존 이름 유지 — 디버깅 스크립트 호환) */
|
||||
const TRACKING_KEY = '__g7LocalInitTracking';
|
||||
|
||||
/**
|
||||
* `_localInit` 전역 추적 레지스트리를 반환합니다. 없으면 생성합니다.
|
||||
*
|
||||
* @returns 전역 추적 레지스트리
|
||||
*/
|
||||
export function getLocalInitTracking(): LocalInitTracking {
|
||||
const target = globalThis as Record<string, any>;
|
||||
|
||||
if (!target[TRACKING_KEY]) {
|
||||
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
|
||||
}
|
||||
|
||||
return target[TRACKING_KEY] as LocalInitTracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추적 레지스트리를 초기화합니다.
|
||||
*
|
||||
* SPA 네비게이션으로 레이아웃이 바뀌면 `_local` 이 통째로 리셋되므로
|
||||
* (`TemplateApp.loadRoute` 의 레이아웃 전환 감지), 추적 해시도 함께 비워야
|
||||
* 새 레이아웃의 `_localInit` 이 이전 레이아웃과 payload 가 우연히 같더라도
|
||||
* 정상 적용된다.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
export function resetLocalInitTracking(): void {
|
||||
const target = globalThis as Record<string, any>;
|
||||
target[TRACKING_KEY] = { hash: '', timestamp: undefined, consumed: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* 렌더러가 관측한 `_localInit` 슬롯을 "소비 완료"로 표시합니다.
|
||||
*
|
||||
* 적용(shouldApply)/건너뜀(동일 해시) 여부와 무관하게 호출한다.
|
||||
* 건너뛴 경우에도 그 payload 는 이미 `_local` 에 반영되어 있으므로 소비된 것이다.
|
||||
*
|
||||
* @param slot 관측한 `_localInit` 슬롯 객체
|
||||
* @returns void
|
||||
*/
|
||||
export function markLocalInitConsumed(slot: unknown): void {
|
||||
getLocalInitTracking().consumed = slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 슬롯이 이미 렌더러에게 관측되었는지 판정합니다.
|
||||
*
|
||||
* @param slot 판정할 `_localInit` 슬롯 객체
|
||||
* @returns 관측 완료 시 true
|
||||
*/
|
||||
export function isLocalInitConsumed(slot: unknown): boolean {
|
||||
return getLocalInitTracking().consumed === slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_forceLocalInit` 타임스탬프를 병합합니다.
|
||||
*
|
||||
* `refetchOnMount: true` 인 소스만 이 값을 갖는다. 두 payload 를 합칠 때
|
||||
* 어느 한쪽에만 있으면 그 값을 보존해야 강제 초기화가 유실되지 않는다.
|
||||
*
|
||||
* @param prev 기존 슬롯의 타임스탬프
|
||||
* @param next 새 payload 의 타임스탬프
|
||||
* @returns 병합된 타임스탬프 (양쪽 모두 없으면 undefined)
|
||||
*/
|
||||
function mergeForceFlag(prev: unknown, next: unknown): unknown {
|
||||
if (typeof prev === 'number' && typeof next === 'number') {
|
||||
return Math.max(prev, next);
|
||||
}
|
||||
|
||||
return next ?? prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_localInit` 슬롯에 새 payload 를 반영합니다.
|
||||
*
|
||||
* - 새 payload 가 없으면 기존 슬롯을 그대로 반환 (참조 동일성 유지 — 소비부 effect 재발화 방지)
|
||||
* - 기존 슬롯이 없거나 이미 소비되었으면 새 payload 로 교체
|
||||
* - 기존 슬롯이 아직 소비되지 않았으면 누적 병합 (나중 payload 우선)
|
||||
*
|
||||
* @param prev 현재 데이터 컨텍스트의 `_localInit` 슬롯
|
||||
* @param next 이번 업데이트가 실어 온 `_localInit` payload
|
||||
* @returns 슬롯에 담길 값
|
||||
*/
|
||||
export function mergeLocalInitSlot(prev: unknown, next: unknown): unknown {
|
||||
if (next === undefined) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const isMergeable =
|
||||
prev !== null &&
|
||||
typeof prev === 'object' &&
|
||||
next !== null &&
|
||||
typeof next === 'object' &&
|
||||
!isLocalInitConsumed(prev);
|
||||
|
||||
if (!isMergeable) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const { _forceLocalInit: prevForce, ...prevData } = prev as Record<string, any>;
|
||||
const { _forceLocalInit: nextForce, ...nextData } = next as Record<string, any>;
|
||||
|
||||
const merged: Record<string, any> = { ...prevData, ...nextData };
|
||||
const force = mergeForceFlag(prevForce, nextForce);
|
||||
|
||||
if (force !== undefined) {
|
||||
merged._forceLocalInit = force;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Playwright 이커머스 인증 fixture.
|
||||
*
|
||||
* 코어 `auth.ts` 의 `issueToken` / `authenticatePage` 헬퍼를 재사용해
|
||||
* 이커머스 도메인 spec 이 쓰는 토큰 fixture 를 구성한다.
|
||||
* 권한 식별자는 임의 string 이므로 코어 헬퍼를 그대로 쓸 수 있다.
|
||||
*
|
||||
* 참조: docs/testing/e2e-testing.md "인증 가드"
|
||||
*/
|
||||
import { test as base } from '@playwright/test';
|
||||
import { authenticatePage, issueToken } from './auth';
|
||||
|
||||
/**
|
||||
* 이커머스 spec 용 토큰 fixture.
|
||||
*
|
||||
* - memberToken : 일반 회원 (마이페이지 주문/리뷰 등 본인 리소스 접근)
|
||||
* - ordersUpdateToken : 주문 상태 변경 권한 (관리자 주문 관리)
|
||||
*
|
||||
* 토큰은 spec 별로 lazy 발급된다.
|
||||
*/
|
||||
type EcommerceAuthFixtures = {
|
||||
memberToken: string;
|
||||
ordersUpdateToken: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<EcommerceAuthFixtures>({
|
||||
memberToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read'));
|
||||
},
|
||||
ordersUpdateToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.update'));
|
||||
},
|
||||
});
|
||||
|
||||
export { authenticatePage, issueToken };
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Smoke: `initLocal` 을 가진 progressive 데이터소스가 둘 이상인 화면에서, SPA 네비게이션으로
|
||||
* 진입할 때 먼저 도착한 소스의 초기값이 유실되던 회귀 (engine-v1.52.2).
|
||||
*
|
||||
* 버그:
|
||||
* progressive 소스는 응답이 오는 대로 각자 `updateTemplateData({ _localInit: 자기 payload })`
|
||||
* 를 호출한다. 그런데 `_localInit` 은 단일 슬롯이고 얕은 스프레드(`...data`)로 교체되었다.
|
||||
* 소비부(DynamicRenderer 의 `_localInit` useEffect)는 React commit 이후에 실행되므로,
|
||||
* 두 번의 updateTemplateData 가 commit 사이에 연달아 들어오면 슬롯에는 마지막 payload 만 남고
|
||||
* 먼저 도착한 소스의 payload 는 한 번도 관측되지 않은 채 사라진다.
|
||||
*
|
||||
* admin_ecommerce_settings.json 은 `settings`(initLocal: "form", refetchOnMount) 와
|
||||
* `ecommerceNotificationDefinitions`(initLocal 맵) 을 모두 progressive 로 갖는다.
|
||||
* 그 결과 배송설정 탭의 배송가능 국가 표가 간헐적으로 비어 "No shipping countries registered."
|
||||
* 가 표시되었다. 직접 URL 접근/새로고침으로는 재현되지 않는다 (SPA 진입에서만 발생).
|
||||
*
|
||||
* 수정:
|
||||
* 아직 어떤 렌더러도 관측하지 않은(unconsumed) `_localInit` 슬롯만 누적 병합한다.
|
||||
* 이미 관측된 슬롯은 종전대로 교체한다 — 그래야 사용자가 폼을 편집한 뒤 다른 소스를 refetch 해도
|
||||
* 소비가 끝난 과거 payload 가 재적용되어 편집 결과를 되돌리지 않는다.
|
||||
*
|
||||
* 검증:
|
||||
* 응답 도착 간격은 원인이 아니다(81ms 실패 / 62ms 통과). 결정 요인은 두 updateTemplateData 사이에
|
||||
* React commit 이 끼어드는지 여부다. 정상 CPU 에서는 간헐적이라 거짓 통과하므로,
|
||||
* CDP `Emulation.setCPUThrottlingRate` 로 commit 을 지연시켜 경합 창을 결정적으로 연다.
|
||||
* (수정 전 번들은 이 조건에서 8/8 실패, 수정 후 8/8 통과 — 브라우저 A/B 실측 확인됨)
|
||||
*
|
||||
* @scenario source=localinit-multi-progressive axis=entry:spa-navigation,cpu:throttled
|
||||
* @effects no-lost-initlocal, table-rendered, no-empty-state
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
/** 이커머스 환경설정 화면 진입에 필요한 권한 토큰 */
|
||||
function settingsToken(): string {
|
||||
return issueToken(
|
||||
'core.settings.read',
|
||||
'sirsoft-ecommerce.settings.read',
|
||||
'core.admin.identity.policies.read',
|
||||
);
|
||||
}
|
||||
|
||||
/** CPU 스로틀링으로 React commit 을 지연시켜 `_localInit` 경합 창을 연다. */
|
||||
async function throttleCpu(page: import('@playwright/test').Page, rate: number): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate });
|
||||
}
|
||||
|
||||
/** SPA 네비게이션으로 배송설정 탭에 진입한다 (직접 URL 접근으로는 재현되지 않는다). */
|
||||
async function navigateToShippingTab(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as any).G7Core.dispatch({ handler: 'navigate', params: { path: '/admin/dashboard' } });
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).G7Core.dispatch({
|
||||
handler: 'navigate',
|
||||
params: { path: '/admin/ecommerce/settings', query: { tab: 'shipping' } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('@smoke initLocal progressive 소스가 둘 이상이어도 먼저 도착한 초기값이 유실되지 않는다', async ({ page }) => {
|
||||
await authenticatePage(page, settingsToken());
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForFunction(() => !!(window as any).G7Core?.dispatch);
|
||||
|
||||
// commit 을 지연시켜 두 progressive 응답이 같은 commit 사이에 도착하도록 만든다.
|
||||
await throttleCpu(page, 6);
|
||||
|
||||
await navigateToShippingTab(page);
|
||||
|
||||
// settings 소스의 initLocal("form") 이 _local 에 반영되어야 한다.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as any).G7Core.state.getLocal()?.form?.shipping?.available_countries?.length ?? null,
|
||||
),
|
||||
{ timeout: 30_000, message: 'settings 데이터소스의 initLocal(form) 이 _local 에 반영되어야 함' },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// 나중에 도착한 notification 소스의 initLocal 도 함께 살아 있어야 한다 (한쪽만 남으면 안 됨).
|
||||
const notifPage = await page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
|
||||
);
|
||||
expect(notifPage).not.toBeNull();
|
||||
|
||||
// 사용자 화면: 국가 표가 렌더되고, 빈 상태 문구는 보이지 않아야 한다.
|
||||
// (상태 반영과 DOM 커밋 사이에 지연이 있으므로 표 렌더를 먼저 기다린다)
|
||||
await expect.poll(() => page.locator('tbody tr').count(), { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByText('No shipping countries registered.')).toBeHidden();
|
||||
});
|
||||
|
||||
test('@smoke 폼 편집 후 다른 데이터소스를 refetch 해도 편집값이 되돌아가지 않는다', async ({ page }) => {
|
||||
await authenticatePage(page, settingsToken());
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForFunction(() => !!(window as any).G7Core?.dispatch);
|
||||
|
||||
await navigateToShippingTab(page);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.available_countries?.length ?? 0,
|
||||
),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// 나중에 도착하는 소스(notification)의 `_localInit` 까지 소비가 끝난 뒤에 편집해야 한다.
|
||||
// 소비 직전에 편집하면 그 편집이 나중 소비에 덮이는데, 그것은 본 규칙이 다루는 상황이 아니다.
|
||||
// 검증 대상은 "소비가 끝난 payload 가 이후 refetch 로 재적용되지 않는가" 이다.
|
||||
//
|
||||
// 마지막 슬롯의 키 구성은 시행마다 다르다 — 두 payload 가 같은 commit 사이에 도착하면 합집합이,
|
||||
// 각각 별도 commit 에서 소비되면 나중 payload 만 담긴다. 둘 다 정상이므로 키 구성에는 기대지 않고,
|
||||
// 두 소스의 initLocal 이 모두 `_local` 에 도달했는지로 판정한다.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
|
||||
),
|
||||
{ timeout: 20_000, message: '나중 도착 소스의 initLocal 까지 소비되어야 함' },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
// 소비 직후의 후속 커밋이 끝나도록 잠시 안정화한다 (편집이 in-flight 소비에 덮이는 것 방지).
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 사용자가 폼을 편집한다. (편집이 _local 에 반영될 때까지 대기 — 고정 sleep 금지)
|
||||
await page.evaluate(() => {
|
||||
const G7Core = (window as any).G7Core;
|
||||
const form = G7Core.state.getLocal().form;
|
||||
G7Core.state.setLocal({
|
||||
form: { ...form, shipping: { ...form.shipping, free_shipping_threshold: 999999 } },
|
||||
});
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.free_shipping_threshold,
|
||||
),
|
||||
{ timeout: 10_000, message: '편집값이 _local 에 반영되어야 함' },
|
||||
)
|
||||
.toBe(999999);
|
||||
|
||||
// 다른 소스를 refetch — 소비가 끝난 settings payload 가 재적용되면 편집이 되돌아간다.
|
||||
// refetch 응답 도착을 명시적으로 기다린 뒤 판정한다 (고정 sleep 은 응답 전 단언 위험).
|
||||
const refetchDone = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/admin/notification-definitions') && res.status() === 200,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
(window as any).G7Core.dispatch({
|
||||
handler: 'refetchDataSource',
|
||||
params: { dataSourceId: 'ecommerceNotificationDefinitions' },
|
||||
});
|
||||
});
|
||||
await refetchDone;
|
||||
|
||||
// refetch 결과가 _local 에 반영될 때까지 기다린 뒤(= 소비 완료), 편집값이 살아 있는지 본다.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.notificationDefinitionCurrentPage ?? null,
|
||||
),
|
||||
{ timeout: 15_000, message: 'refetch 한 소스의 initLocal 이 _local 에 반영되어야 함' },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const threshold = await page.evaluate(
|
||||
() => (window as any).G7Core.state.getLocal()?.form?.shipping?.free_shipping_threshold,
|
||||
);
|
||||
expect(threshold).toBe(999999);
|
||||
});
|
||||
Reference in New Issue
Block a user