표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은 회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다. - 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 / _computed stale 전파 / 원본 타입 소실) - 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설 - 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인 - BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환 - iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬 - 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임 - 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지 못하던 결함 수정 - SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다 - audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소, 신규 룰 5종 등재
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
/**
|
|
* E2E: `iteration` 과 `if` 를 함께 쓴 노드가 항목별로 조건을 평가하는지 (배포 번들 기준)
|
|
*
|
|
* 배경: 조건이 항목 변수(`{{comment.depth === 0}}` 등)를 참조하면, 부모 시점 컨텍스트에는
|
|
* 그 변수가 없다. 조건을 iteration 보다 먼저 평가하던 종전 구조에서는 조건이 항상 거짓이
|
|
* 되어 **목록 전체가 사라졌다** — 예외도 콘솔 경고도 남지 않으므로 API 응답만 봐서는
|
|
* 드러나지 않는다. 같은 작성이 반복 렌더 경로(`cellChildren`)에서는 정상 동작했기 때문에
|
|
* 재현 위치를 특정하기 어려운 결함이었다.
|
|
*
|
|
* 단위 테스트(`DynamicRenderer.iterationCondition.test.tsx`)가 소스를 잠그고
|
|
* `SeoIterationConditionParityTest` 가 봇(PHP) 렌더러를 잠그지만, 실제 사용자가 보는 것은
|
|
* 빌드된 번들이므로 브라우저에서 배포 번들의 `DynamicRenderer` 를 직접 렌더해 결과를 잠근다.
|
|
*
|
|
* 두 렌더러를 동시에 바꾼 수정이라 한쪽만 되돌아가면 브라우저에서는 목록이 보이는데
|
|
* 봇 화면에서만 비는 비대칭이 생긴다.
|
|
*
|
|
* @scenario iteration_item_condition, iteration_item_index_variable
|
|
* @effects iteration_renders_matching_items, iteration_injects_item_index
|
|
*/
|
|
import { test, expect } from '../fixtures/auth';
|
|
|
|
/**
|
|
* 배포 번들에서 `DynamicRenderer` 를 임의 컨테이너에 렌더하고 결과 텍스트를 돌려줍니다.
|
|
*
|
|
* 엔진 인스턴스는 반드시 `G7Core.get*()` 접근자로 얻는다 — 클래스의 `getInstance()` 싱글톤은
|
|
* 화면이 실제로 쓰는 인스턴스가 아니라서(템플릿 부팅 시 앱이 자기 레지스트리를 주입한다)
|
|
* 컴포넌트가 하나도 등록돼 있지 않고 렌더 결과가 빈 문자열이 된다.
|
|
*/
|
|
const RENDER_IN_BUNDLE = `
|
|
(componentDef, dataContext) => {
|
|
const g7 = window.G7Core;
|
|
const runtime = g7 && g7.__runtime;
|
|
const React = window.React;
|
|
const ReactDOM = window.ReactDOM;
|
|
|
|
const missing = [];
|
|
if (!runtime) missing.push('G7Core.__runtime');
|
|
if (!React) missing.push('window.React');
|
|
if (!ReactDOM || !ReactDOM.createRoot) missing.push('window.ReactDOM.createRoot');
|
|
if (runtime && !runtime.DynamicRenderer) missing.push('__runtime.DynamicRenderer');
|
|
for (const name of ['getComponentRegistry', 'getDataBindingEngine', 'getTranslationEngine', 'getActionDispatcher']) {
|
|
if (!g7 || typeof g7[name] !== 'function') missing.push('G7Core.' + name);
|
|
}
|
|
if (missing.length) return { missing };
|
|
|
|
const registry = g7.getComponentRegistry();
|
|
if (!registry || !registry.hasComponent('Div')) {
|
|
return { missing: ['registry:Div'], texts: [], html: '' };
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
|
|
const element = React.createElement(runtime.DynamicRenderer, {
|
|
componentDef,
|
|
dataContext,
|
|
translationContext: { templateId: 'e2e', locale: 'ko' },
|
|
registry,
|
|
bindingEngine: g7.getDataBindingEngine(),
|
|
translationEngine: g7.getTranslationEngine(),
|
|
actionDispatcher: g7.getActionDispatcher(),
|
|
});
|
|
|
|
const root = ReactDOM.createRoot(container);
|
|
return new Promise((resolve) => {
|
|
root.render(element);
|
|
// 커밋이 끝난 뒤 DOM 을 읽는다 (두 프레임이면 초기 effect 까지 반영된다).
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
const texts = Array.from(container.querySelectorAll('.e2e-item')).map(
|
|
(n) => (n.textContent || '').trim()
|
|
);
|
|
const html = container.innerHTML;
|
|
root.unmount();
|
|
container.remove();
|
|
resolve({ missing: [], texts, html });
|
|
}));
|
|
});
|
|
}
|
|
`;
|
|
|
|
test.describe('iteration + if 항목별 조건 평가', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// 코어 번들이 부팅된 화면이면 충분하다(로그인 화면도 엔진을 부팅한다).
|
|
await page.goto('/admin/login');
|
|
// 컴포넌트 등록은 템플릿 부팅이 끝난 뒤에 일어난다. `__templateApp` 존재만 기다리면
|
|
// 레지스트리가 비어 있는 시점에 렌더해 결과가 빈 문자열이 되고, 그러면 이 테스트가
|
|
// "항목이 0개" 를 정상으로 오판한다.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const g7 = (window as any).G7Core;
|
|
return Boolean(
|
|
g7?.__runtime?.DynamicRenderer &&
|
|
typeof g7.getComponentRegistry === 'function' &&
|
|
g7.getComponentRegistry()?.hasComponent?.('Div')
|
|
);
|
|
},
|
|
null,
|
|
{ timeout: 30_000 }
|
|
);
|
|
});
|
|
|
|
// @scenario iteration_item_condition
|
|
// @effects iteration_renders_matching_items
|
|
test('항목 변수를 참조하는 if 가 목록 전체를 지우지 않는다', async ({ page }) => {
|
|
const result: any = await page.evaluate(
|
|
([source, componentDef, dataContext]) =>
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(source as string)(componentDef, dataContext),
|
|
[
|
|
RENDER_IN_BUNDLE,
|
|
{
|
|
type: 'basic',
|
|
name: 'Div',
|
|
if: '{{user.is_active}}',
|
|
iteration: { source: '{{users}}', item_var: 'user' },
|
|
props: { className: 'e2e-item' },
|
|
text: '{{user.name}}',
|
|
},
|
|
{
|
|
users: [
|
|
{ name: '가', is_active: true },
|
|
{ name: '나', is_active: false },
|
|
{ name: '다', is_active: true },
|
|
],
|
|
},
|
|
] as any
|
|
);
|
|
|
|
expect(result.missing, `배포 번들에 필요한 런타임 표면이 없습니다: ${result.missing?.join(', ')}`).toEqual([]);
|
|
// 수정 전: 부모 시점에 `user` 가 없어 조건이 거짓 → 항목이 0개(목록 전체 소실)
|
|
expect(result.texts, '항목 조건이 항목별로 평가되지 않았습니다').toEqual(['가', '다']);
|
|
});
|
|
|
|
// @scenario iteration_item_condition
|
|
// @effects iteration_renders_matching_items
|
|
test('외곽 변수만 참조하는 if 는 종전대로 목록 전체를 차단한다', async ({ page }) => {
|
|
const blocked: any = await page.evaluate(
|
|
([source, componentDef, dataContext]) =>
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(source as string)(componentDef, dataContext),
|
|
[
|
|
RENDER_IN_BUNDLE,
|
|
{
|
|
type: 'basic',
|
|
name: 'Div',
|
|
if: '{{showList}}',
|
|
iteration: { source: '{{items}}', item_var: 'item' },
|
|
props: { className: 'e2e-item' },
|
|
text: '{{item.name}}',
|
|
},
|
|
{ showList: false, items: [{ name: '가' }, { name: '나' }] },
|
|
] as any
|
|
);
|
|
|
|
expect(blocked.missing).toEqual([]);
|
|
expect(blocked.texts, '외곽 조건이 거짓인데 항목이 렌더됐습니다').toEqual([]);
|
|
|
|
const shown: any = await page.evaluate(
|
|
([source, componentDef, dataContext]) =>
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(source as string)(componentDef, dataContext),
|
|
[
|
|
RENDER_IN_BUNDLE,
|
|
{
|
|
type: 'basic',
|
|
name: 'Div',
|
|
if: '{{showList}}',
|
|
iteration: { source: '{{items}}', item_var: 'item' },
|
|
props: { className: 'e2e-item' },
|
|
text: '{{item.name}}',
|
|
},
|
|
{ showList: true, items: [{ name: '가' }, { name: '나' }] },
|
|
] as any
|
|
);
|
|
|
|
expect(shown.texts).toEqual(['가', '나']);
|
|
});
|
|
|
|
// @scenario iteration_item_index_variable
|
|
// @effects iteration_injects_item_index
|
|
test('{item_var}_index 자동 변수가 단발 렌더 경로에도 주입된다', async ({ page }) => {
|
|
const result: any = await page.evaluate(
|
|
([source, componentDef, dataContext]) =>
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(source as string)(componentDef, dataContext),
|
|
[
|
|
RENDER_IN_BUNDLE,
|
|
{
|
|
type: 'basic',
|
|
name: 'Div',
|
|
iteration: { source: '{{items}}', item_var: 'row' },
|
|
props: { className: 'e2e-item' },
|
|
text: '{{row_index}}',
|
|
},
|
|
{ items: [{ name: '가' }, { name: '나' }, { name: '다' }] },
|
|
] as any
|
|
);
|
|
|
|
expect(result.missing).toEqual([]);
|
|
// 반복 렌더 경로(renderItemChildren)에는 원래 있던 변수다 — 경로 간 격차 해소
|
|
expect(result.texts).toEqual(['0', '1', '2']);
|
|
});
|
|
|
|
// @scenario iteration_item_condition
|
|
// @effects iteration_renders_matching_items
|
|
test('저장소 실사용 형태(게시판 댓글 접기 조건)가 항목별로 평가된다', async ({ page }) => {
|
|
// 아래 조건식은 합성 예시가 아니라 저장소에 실제로 쓰인 작성 그대로다.
|
|
// modules/_bundled/sirsoft-board/resources/layouts/admin/partials/
|
|
// admin_board_post_detail/_comments.json
|
|
// 항목 변수(`comment`)와 외곽 상태(`_local`/`$computed`)를 함께 참조하는 형태라,
|
|
// 수정 전에는 부모 시점에 `comment` 가 없어 조건이 거짓 → 댓글 목록이 통째로 비었다.
|
|
const condition =
|
|
'{{(comment?.depth ?? 0) === 0 || (_local.collapsedReplies?.[$computed.commentRootMap?.[comment.id]] === false)}}';
|
|
|
|
const result: any = await page.evaluate(
|
|
([source, componentDef, dataContext]) =>
|
|
// eslint-disable-next-line no-eval
|
|
(0, eval)(source as string)(componentDef, dataContext),
|
|
[
|
|
RENDER_IN_BUNDLE,
|
|
{
|
|
type: 'basic',
|
|
name: 'Div',
|
|
if: condition,
|
|
iteration: { source: '{{comments}}', item_var: 'comment' },
|
|
props: { className: 'e2e-item' },
|
|
text: '{{comment.id}}',
|
|
},
|
|
{
|
|
comments: [
|
|
{ id: 'c1', depth: 0 },
|
|
// 답글이지만 부모가 펼쳐진 상태 → 표시
|
|
{ id: 'c2', depth: 1 },
|
|
// 답글이고 부모가 접힌 상태 → 미표시
|
|
{ id: 'c3', depth: 1 },
|
|
],
|
|
_local: { collapsedReplies: { c1: false, root2: true } },
|
|
$computed: { commentRootMap: { c2: 'c1', c3: 'root2' } },
|
|
},
|
|
] as any
|
|
);
|
|
|
|
expect(result.missing).toEqual([]);
|
|
expect(result.texts, '실사용 조건이 항목별로 평가되지 않았습니다').toEqual(['c1', 'c2']);
|
|
});
|
|
});
|