From ef015cf588b744b6b091f4c169eb55386c6d4280 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:47:07 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix:=20=EB=8B=A4=ED=81=AC=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=B3=B4=EC=A1=B0=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20muted=C2=B7secondary=C2=B7accent=20=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8F=84=20AA=20=EB=8C=80=EB=B9=84=EA=B0=80?= =?UTF-8?q?=20=EB=82=98=EC=98=A4=EA=B2=8C=20=EB=B0=9D=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다크 --muted-foreground #85859a가 muted·secondary(#26262f) 위에서 4.15:1, accent·surface-tint(#201f2c) 위에서 4.50:1 미만(4.498)이라 본문 크기 기준 AA 4.5:1 미달. 대비 spec은 다크 background·card만 보고 있어 놓침 - 색상·채도(OKLCH C 0.031, H 285.5)는 그대로 두고 명도만 올려, 다크 면 전부에서 4.6:1 이상이 되는 가장 어두운 값 #8d8da2로 변경 - background 5.65, card·popover 5.21, muted·secondary 4.62, accent·surface-tint 5.00, sidebar 5.49 - foreground(#f0f0f4)와는 2.86:1로 여전히 한 단계 흐린 보조 텍스트 - 대비 spec에 다크 popover·muted·secondary·accent·surface-tint·sidebar 6행 추가(이전 색으로 되돌리면 muted·secondary·accent·surface-tint 4행 실패 확인) --- src/app/globals.css | 2 +- src/app/globals.spec.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index ada87bb..569786a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -139,7 +139,7 @@ --secondary: #26262f; --secondary-foreground: #f0f0f4; --muted: #26262f; - --muted-foreground: #85859a; + --muted-foreground: #8d8da2; --accent: #201f2c; --accent-foreground: #f0f0f4; --destructive: #ff7d80; diff --git a/src/app/globals.spec.ts b/src/app/globals.spec.ts index 77bf5f4..d740208 100644 --- a/src/app/globals.spec.ts +++ b/src/app/globals.spec.ts @@ -31,6 +31,12 @@ describe('디자인 토큰 대비', () => { [':root', 'popover'], ['.dark', 'background'], ['.dark', 'card'], + ['.dark', 'popover'], + ['.dark', 'muted'], + ['.dark', 'secondary'], + ['.dark', 'accent'], + ['.dark', 'surface-tint'], + ['.dark', 'sidebar'], ] as const)('%s 보조 텍스트는 %s 위에서 4.5:1 이상이다', (selector, surface) => { const t = tokens(selector); expect(t['muted-foreground']).toBeDefined(); From dbe6ac4f989d772acd929c3997285042e2f03d51 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:09:51 +0900 Subject: [PATCH 02/14] =?UTF-8?q?chore:=20BE=20develop=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index 342fd26..8fb0baa 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@234063bf62d2bf40170c1b3bae454081977ce981 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@77976b55ccf2d2dd32305b66ebf001a5375098cc 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- From 0e1302ef45af41770647d84969fdeaf3824f59ef Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:49:03 +0900 Subject: [PATCH 03/14] =?UTF-8?q?test:=20=EC=95=B1=20=EB=B6=80=ED=8C=85?= =?UTF-8?q?=EC=9D=B4=20zod=20=ED=95=9C=EA=B5=AD=EC=96=B4=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EB=A5=BC=20=EC=84=A4=EC=B9=98=ED=95=98=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 setup과 zod-locale spec이 installZodKorean()을 직접 불러서, main.tsx에서 호출이 빠져도 잡는 테스트가 없었음 - 부팅을 함수로 떼어 그 함수를 검사하면 main.tsx의 호출 자체는 여전히 비어 있으므로, main.tsx를 직접 import하는 spec으로 바꿈 - react-dom/client의 createRoot만 stub, #root를 둔 뒤 zod를 영어 로캘·customError 없음으로 되돌리고 import - 메시지 없는 규칙(min(1))이 한국어 문구를 내고, #root로 render가 1회 불리는지 확인. 끝나면 한국어 설정을 다시 설치 - 회귀 1건(main.tsx의 installZodKorean() 줄을 지우면 영어 문구가 나와 실패 확인) --- src/main.spec.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main.spec.ts diff --git a/src/main.spec.ts b/src/main.spec.ts new file mode 100644 index 0000000..61e73ee --- /dev/null +++ b/src/main.spec.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { installZodKorean } from '@/shared/lib/zod-locale'; + +const { createRoot, render } = vi.hoisted(() => { + const render = vi.fn(); + return { render, createRoot: vi.fn(() => ({ render })) }; +}); +vi.mock('react-dom/client', () => ({ createRoot })); + +const emptyMessage = () => z.string().min(1).safeParse('').error?.issues[0]?.message; + +/** 테스트 setup도 한국어 문구를 설치하므로, main.tsx에서 설치가 빠져도 다른 spec은 모른다. */ +describe('앱 부팅(main.tsx)', () => { + afterEach(() => { + installZodKorean(); + document.getElementById('root')?.remove(); + }); + + it('zod 한국어 문구를 설치하고 #root에 앱을 한 번 렌더한다', async () => { + z.config({ ...z.locales.en(), customError: undefined }); + expect(emptyMessage()).not.toBe('값을 입력해 주세요.'); + const root = document.createElement('div'); + root.id = 'root'; + document.body.append(root); + + await import('./main'); + + expect(emptyMessage()).toBe('값을 입력해 주세요.'); + expect(createRoot).toHaveBeenCalledWith(root); + expect(render).toHaveBeenCalledTimes(1); + }); +}); From e08de2366f9fdffd4354c8b8846d1c81c9d40f0b Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:55:07 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=AA=A9=EB=A1=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EC=95=84=EC=98=A4=EB=A9=B4=20CursorPager?= =?UTF-8?q?=EC=9D=98=20'=EC=9D=B4=EC=A0=84'=EA=B3=BC=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=90=98=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지나온 커서를 컴포넌트 상태에만 쌓아서, 상세 → '목록으로'로 같은 커서 주소에 돌아와도 페이저가 다시 마운트되며 [cursor]부터 시작 → '이전' 버튼과 "21–40 / 전체" 구간 표시가 사라졌음 - list-return에 경로별 커서 기억(rememberTrail·rememberedTrail) 추가. 검색 파라미터 기억과 같은 수명(최대 50경로, forgetSearches가 함께 비움) - CursorPager는 마운트 때 라우터에서 경로를 읽어 기억한 것으로 시작하고, 쌓은 커서가 바뀔 때마다 기록 - 기억한 것이 다른 목록 조건이거나 지금 커서를 지나오지 않았으면 기존 렌더 중 보정이 새로 쌓음(이전 동작) - 라우터 밖에서 단독 렌더하면 경로가 없어 기억하지 않음 → 호출부(11곳)·기존 spec은 그대로 - 회귀 5건: 같은 경로·조건·커서로 다시 마운트하면 '이전'·구간 복원 후 '이전'이 앞 커서로 이동 / 경로·조건이 다르거나 지나오지 않은 커서면 복원 안 함 / forgetSearches 뒤 복원 안 함 - 페이저 변경을 되돌리면 복원 케이스, 경로를 무시하면 경로 케이스, forgetSearches에서 커서를 안 비우면 세션 케이스가 실패하는 것 확인 --- src/shared/lib/list-return.ts | 31 +++++++++++-- src/shared/ui/cursor-pager.spec.tsx | 67 +++++++++++++++++++++++++++++ src/shared/ui/cursor-pager.tsx | 23 ++++++---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/shared/lib/list-return.ts b/src/shared/lib/list-return.ts index c8d03f4..534cf53 100644 --- a/src/shared/lib/list-return.ts +++ b/src/shared/lib/list-return.ts @@ -7,20 +7,43 @@ const MAX_PATHS = 50; const normalize = (pathname: string) => pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname; -export function rememberSearch(pathname: string, search: Record) { +/** CursorPager가 지나온 커서. 목록으로 돌아와 다시 마운트돼도 '이전'과 구간 표시를 되살린다 */ +export interface CursorTrail { + /** 커서를 뺀 목록 조건 */ + filterKey: string; + /** 지나온 커서 순서. undefined는 첫 페이지 */ + cursors: (string | undefined)[]; +} +const lastTrail = new Map(); + +function remember(map: Map, pathname: string, value: T) { const key = normalize(pathname); - lastSearch.delete(key); - lastSearch.set(key, search); + map.delete(key); + map.set(key, value); // 상세 경로는 ID마다 쌓이므로 오래된 것부터 버린다 - if (lastSearch.size > MAX_PATHS) lastSearch.delete(lastSearch.keys().next().value!); + if (map.size > MAX_PATHS) map.delete(map.keys().next().value!); +} + +export function rememberSearch(pathname: string, search: Record) { + remember(lastSearch, pathname, search); } export function rememberedSearch(pathname: string): Record | undefined { return lastSearch.get(normalize(pathname)); } +export function rememberTrail(pathname: string, trail: CursorTrail) { + remember(lastTrail, pathname, trail); +} + +export function rememberedTrail(pathname: string): CursorTrail | undefined { + return lastTrail.get(normalize(pathname)); +} + +/** 세션을 잃으면 기억한 검색 파라미터·커서를 모두 비운다 */ export function forgetSearches() { lastSearch.clear(); + lastTrail.clear(); } /** 라우터 이동이 끝날 때마다 위치를 기록한다. 반환값은 구독 해제 함수. */ diff --git a/src/shared/ui/cursor-pager.spec.tsx b/src/shared/ui/cursor-pager.spec.tsx index dcec04c..2052725 100644 --- a/src/shared/ui/cursor-pager.spec.tsx +++ b/src/shared/ui/cursor-pager.spec.tsx @@ -1,7 +1,17 @@ +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useState } from 'react'; +import { forgetSearches } from '@/shared/lib/list-return'; + import { CursorPager } from './cursor-pager'; const TOTAL = 45; @@ -44,6 +54,23 @@ function Harness({ initial = {} }: { initial?: Search }) { const button = (name: string) => screen.queryByRole('button', { name }); +/** 목록 화면처럼 라우터 아래 경로에 붙여 렌더한다. 검색 파라미터는 Harness가 들고 있다 */ +function renderAt(pathname: string, initial: Search = {}) { + const root = createRootRoute({ component: Outlet }); + const routes = ['/stores', '/orders'].map((path) => + createRoute({ + getParentRoute: () => root, + path, + component: () => , + }), + ); + const router = createRouter({ + routeTree: root.addChildren(routes), + history: createMemoryHistory({ initialEntries: [pathname] }), + }); + return render(); +} + describe('CursorPager', () => { it('다음으로 가며 지나온 커서를 쌓고, 이전으로 한 페이지씩 돌아온다', async () => { render(); @@ -130,6 +157,46 @@ describe('CursorPager', () => { expect(screen.getByText('21–30 / 전체 45건')).toBeInTheDocument(); }); + describe('목록으로 돌아와 다시 마운트될 때', () => { + afterEach(() => forgetSearches()); + + /** 첫 페이지부터 두 번 다음으로 가 41–45 구간에서 화면을 떠난다 */ + async function leaveAtThirdPage() { + const { unmount } = renderAt('/stores'); + await userEvent.click(await screen.findByRole('button', { name: '다음' })); + await userEvent.click(button('다음')!); + expect(screen.getByText('41–45 / 전체 45건')).toBeInTheDocument(); + unmount(); + } + + it('같은 경로·목록 조건이고 그 커서를 지나왔으면 이전과 구간을 되살린다', async () => { + await leaveAtThirdPage(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('41–45 / 전체 45건')).toBeInTheDocument(); + await userEvent.click(button('이전')!); + expect(screen.getByText('21–40 / 전체 45건')).toBeInTheDocument(); + }); + + it.each<[string, string, Search]>([ + ['경로가 다르면', '/orders', { cursor: 'c40' }], + ['목록 조건이 다르면', '/stores', { cursor: 'c40', q: '케이크' }], + ['지나오지 않은 커서면', '/stores', { cursor: 'c30' }], + ])('%s 되살리지 않는다', async (_, pathname, initial) => { + await leaveAtThirdPage(); + renderAt(pathname, initial); + expect(await screen.findByRole('button', { name: '처음' })).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + + it('세션을 비우면(forgetSearches) 되살리지 않는다', async () => { + await leaveAtThirdPage(); + forgetSearches(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('전체 45건 중 5건 표시')).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + }); + it('결과가 없으면 0건으로 보인다', () => { render( ({ filterKey, cursors: [cursor] }); + // 라우터 밖(단독 렌더)이면 경로가 없어 기억하지 않는다. 경로는 목록 화면마다 고정이라 마운트 때 한 번 읽는다 + const router: AnyRouter | null = useRouter({ warn: false }); + const [pathname] = useState(() => router?.state.location.pathname); + // 기억한 것이 다른 조건이거나 지금 커서를 지나오지 않았으면 아래 보정이 새로 쌓는다 + const [trail, setTrail] = useState(() => { + const saved = pathname === undefined ? undefined : rememberedTrail(pathname); + return saved ?? { filterKey, cursors: [cursor] }; + }); + useEffect(() => { + if (pathname !== undefined) rememberTrail(pathname, trail); + }, [pathname, trail]); let current = trail; if (trail.filterKey !== filterKey || !trail.cursors.includes(cursor)) { From e62ea1f0e54334c06f98fa05dbe39ee00d5cc144 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:49:35 +0900 Subject: [PATCH 05/14] =?UTF-8?q?chore:=20BE=20develop=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index 8fb0baa..e2e6c8a 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@77976b55ccf2d2dd32305b66ebf001a5375098cc 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@7bece88a93b583c4c2d27ba42438cea7f01c9a0d 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- From d627e3120d0c87d547c627306bd2700dbca4d015 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:59:32 +0900 Subject: [PATCH 06/14] =?UTF-8?q?chore:=20BE=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminBanner.linkTargetAvailable 추가분(BE feat/admin-banner-link-target e76ccf9 기준) - BE 머지 뒤 develop 기준으로 다시 동기화한다 --- schema/schema.graphql | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index e2e6c8a..db9aca2 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@7bece88a93b583c4c2d27ba42438cea7f01c9a0d 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@e76ccf91dfa38aa98acc2b4449e0e76610eb1610 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- @@ -3145,6 +3145,11 @@ type AdminBanner { """ linkCategoryId: ID """ + 링크 대상이 지금 노출 가능한지. 구매자 앱과 같은 기준(상품·매장·카테고리가 활성·미삭제, 상품은 소속 매장까지)이며, + false면 구매자 앱이 이 배너를 건너뛰고 다음 배너를 노출한다. linkType이 NONE·URL이면 항상 true. + """ + linkTargetAvailable: Boolean! + """ 노출 시작 일시. null이면 시작 제한 없음. """ startsAt: DateTime From 8836bd9796a6433b456353b5341561e98f803cd6 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:59:53 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=EB=B0=B0=EB=84=88=20'=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=85=B8=EC=B6=9C'=EC=9D=B4=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EB=8C=80=EC=83=81=20=EC=88=A8=EA=B9=80=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=EB=A5=BC=20=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B3=A0=20'=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=8C=80=EC=83=81=20=EC=88=A8=EA=B9=80'=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 연결한 상품·매장·카테고리가 나중에 숨겨지거나 삭제된 배너도 '현재 노출'로 표시했다. 구매자 앱은 그런 배너를 건너뛰고 다음 배너를 보여 주는데, 목록은 링크 대상 상태를 몰랐다. - AdminBanners·AdminBannersVisible 문서에 linkTargetAvailable 추가(BE가 구매자 조회와 같은 조건으로 판정) - currentBannerIds: linkTargetAvailable=false면 슬롯 후보에서 빼 구매자 앱과 같은 배너를 고름 - 목록 노출 상태 칸에 '링크 대상 숨김'(caution) 배지, 페이지 설명 문구 갱신 회귀 테스트 - live-status.spec: 대상 숨김인 상위 배너를 건너뛰고 다음 배너가 현재 노출 - banners.spec: 상위 배너는 '노출 중 + 링크 대상 숨김', 다음 배너가 '현재 노출' - 반증: 건너뛰기를 되돌리면 2건, 배지를 빼면 1건 실패 --- src/features/banners/api/queries.ts | 2 ++ src/features/banners/live-status.spec.ts | 16 +++++++++- src/features/banners/live-status.ts | 5 ++-- .../banners/pages/banners-list-page.tsx | 5 +++- src/features/banners/pages/banners.spec.tsx | 30 +++++++++++++++++++ src/graphql/generated/gql.ts | 12 ++++---- src/graphql/generated/graphql.ts | 6 ++-- 7 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/features/banners/api/queries.ts b/src/features/banners/api/queries.ts index 1770785..a9f459b 100644 --- a/src/features/banners/api/queries.ts +++ b/src/features/banners/api/queries.ts @@ -39,6 +39,7 @@ const AdminBannersDocument = graphql(/* GraphQL */ ` linkProductId linkStoreId linkCategoryId + linkTargetAvailable startsAt endsAt sortOrder @@ -101,6 +102,7 @@ const AdminBannersVisibleDocument = graphql(/* GraphQL */ ` id placement linkCategoryId + linkTargetAvailable startsAt endsAt sortOrder diff --git a/src/features/banners/live-status.spec.ts b/src/features/banners/live-status.spec.ts index 806b40e..7db41a0 100644 --- a/src/features/banners/live-status.spec.ts +++ b/src/features/banners/live-status.spec.ts @@ -5,7 +5,13 @@ const BEFORE = '2026-09-30T23:59:59.999Z'; const AFTER = '2026-10-01T00:00:00.001Z'; describe('배너 현재 노출', () => { - const live = { isActive: true, startsAt: null, endsAt: null, linkCategoryId: null }; + const live = { + isActive: true, + startsAt: null, + endsAt: null, + linkCategoryId: null, + linkTargetAvailable: true, + }; it.each([ { name: '슬롯마다 정렬 순서가 가장 작은 것', @@ -34,6 +40,14 @@ describe('배너 현재 노출', () => { ], want: ['4'], }, + { + name: '링크 대상이 숨김이면 구매자 앱처럼 건너뛰고 다음 배너', + banners: [ + { ...live, id: '1', placement: 'HOME_MAIN', sortOrder: 0, linkTargetAvailable: false }, + { ...live, id: '2', placement: 'HOME_MAIN', sortOrder: 1 }, + ], + want: ['2'], + }, { name: '카테고리 배치는 카테고리마다 따로 고른다', banners: [ diff --git a/src/features/banners/live-status.ts b/src/features/banners/live-status.ts index 381ab02..60ed6da 100644 --- a/src/features/banners/live-status.ts +++ b/src/features/banners/live-status.ts @@ -5,6 +5,7 @@ interface SlotBanner extends Timed { id: string; placement: BannerPlacement; linkCategoryId?: string | null; + linkTargetAvailable: boolean; sortOrder: number; } @@ -26,13 +27,13 @@ const byId = (a: string, b: string) => /** * 슬롯마다 지금 구매자에게 보이는 배너 1개의 ID. 노출 중인 것 가운데 정렬 순서가 가장 작고, 같으면 먼저 등록한(ID가 작은) 것. - * 연결한 상품·매장·카테고리가 숨김이면 구매자 앱은 다음 배너로 넘어가는데, 이 목록은 그 상태를 모른다. + * 연결한 상품·매장·카테고리가 숨김이면(linkTargetAvailable=false) 구매자 앱처럼 건너뛰고 다음 배너를 고른다. */ export function currentBannerIds(banners: readonly SlotBanner[], now: Date): Set { const winners = new Map(); for (const b of banners) { const key = slotKey(b); - if (key === null || liveStatus(b, now) !== 'LIVE') continue; + if (key === null || !b.linkTargetAvailable || liveStatus(b, now) !== 'LIVE') continue; const cur = winners.get(key); if ( !cur || diff --git a/src/features/banners/pages/banners-list-page.tsx b/src/features/banners/pages/banners-list-page.tsx index 52075fe..b3d6499 100644 --- a/src/features/banners/pages/banners-list-page.tsx +++ b/src/features/banners/pages/banners-list-page.tsx @@ -87,6 +87,9 @@ export function BannersListPage({ search, onSearchChange }: Props) { {s.label} {current.has(row.original.id) && 현재 노출} + {!row.original.linkTargetAvailable && ( + 링크 대상 숨김 + )} ); }, @@ -142,7 +145,7 @@ export function BannersListPage({ search, onSearchChange }: Props) { <> diff --git a/src/features/banners/pages/banners.spec.tsx b/src/features/banners/pages/banners.spec.tsx index 75e8cac..f3e76c3 100644 --- a/src/features/banners/pages/banners.spec.tsx +++ b/src/features/banners/pages/banners.spec.tsx @@ -27,6 +27,7 @@ const banner = { linkProductId: null, linkStoreId: null, linkCategoryId: null, + linkTargetAvailable: true, startsAt: null, endsAt: null, sortOrder: 0, @@ -117,6 +118,35 @@ describe('배너', () => { await vi.waitFor(() => expect(deleted).toEqual({ bannerId: '5' })); }); + it('목록: 링크 대상이 숨김인 배너는 현재 노출에서 건너뛰고 링크 대상 숨김으로 표시한다', async () => { + const rows = [ + { + ...banner, + id: '5', + title: '대상 숨김', + linkType: 'PRODUCT', + linkProductId: '3', + linkTargetAvailable: false, + }, + { ...banner, id: '6', title: '다음 배너', sortOrder: 1 }, + ]; + server.use( + gqlOk('AdminBanners', { + adminBanners: { items: rows, totalCount: 2, hasMore: false, nextCursor: null }, + }), + gqlOk('AdminBannersVisible', { + adminBanners: { items: rows, hasMore: false, nextCursor: null }, + }), + ); + boot('/banners'); + const statusOf = async (title: string) => + within((await screen.findByRole('link', { name: title })).closest('tr')!).getAllByRole( + 'cell', + )[2]!.textContent; + await vi.waitFor(async () => expect(await statusOf('다음 배너')).toBe('노출 중현재 노출')); + expect(await statusOf('대상 숨김')).toBe('노출 중링크 대상 숨김'); + }); + it('목록을 켜 둔 채 시작 시각이 되면 예약이 노출 중·현재 노출로 바뀐다', async () => { // 실제 시간도 흐르게 둬 부팅·요청은 그대로 진행되고, 시작 시각만 앞당겨 넘긴다 vi.useFakeTimers({ shouldAdvanceTime: true }); diff --git a/src/graphql/generated/gql.ts b/src/graphql/generated/gql.ts index af13946..c8019d1 100644 --- a/src/graphql/generated/gql.ts +++ b/src/graphql/generated/gql.ts @@ -26,12 +26,12 @@ type Documents = { "\n query AdminAuditLogs($input: AdminAuditLogListInput) {\n adminAuditLogs(input: $input) {\n items {\n id\n actorAccountId\n actorAccountType\n actorLabel\n storeId\n targetType\n targetId\n action\n beforeJson\n afterJson\n ipAddress\n userAgent\n createdAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminAuditLogsDocument, "\n query AdminAuditStorePicker($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n isActive\n }\n }\n }\n": typeof types.AdminAuditStorePickerDocument, "\n query AdminAuditActorPicker($sellers: AdminSellerListInput, $admins: CursorInput) {\n adminSellers(input: $sellers) {\n items {\n accountId\n username\n name\n }\n }\n adminAdmins(input: $admins) {\n items {\n accountId\n username\n name\n }\n }\n }\n": typeof types.AdminAuditActorPickerDocument, - "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersDocument, + "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersDocument, "\n query AdminBanner($bannerId: ID!) {\n adminBanner(bannerId: $bannerId) {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n }\n": typeof types.AdminBannerDocument, "\n mutation AdminCreateBanner($input: AdminCreateBannerInput!) {\n adminCreateBanner(input: $input) {\n id\n }\n }\n": typeof types.AdminCreateBannerDocument, "\n mutation AdminUpdateBanner($input: AdminUpdateBannerInput!) {\n adminUpdateBanner(input: $input) {\n id\n updatedAt\n }\n }\n": typeof types.AdminUpdateBannerDocument, "\n mutation AdminDeleteBanner($bannerId: ID!) {\n adminDeleteBanner(bannerId: $bannerId)\n }\n": typeof types.AdminDeleteBannerDocument, - "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersVisibleDocument, + "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": typeof types.AdminBannersVisibleDocument, "\n query AdminBannerProductOptions($input: AdminProductListInput) {\n adminProducts(input: $input) {\n items {\n id\n name\n storeName\n storeIsActive\n }\n }\n }\n": typeof types.AdminBannerProductOptionsDocument, "\n query AdminBannerStoreOptions($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n }\n }\n }\n": typeof types.AdminBannerStoreOptionsDocument, "\n query AdminBannerCategoryOptions($input: AdminCategoryListInput) {\n adminCategories(input: $input) {\n id\n name\n isActive\n }\n }\n": typeof types.AdminBannerCategoryOptionsDocument, @@ -104,12 +104,12 @@ const documents: Documents = { "\n query AdminAuditLogs($input: AdminAuditLogListInput) {\n adminAuditLogs(input: $input) {\n items {\n id\n actorAccountId\n actorAccountType\n actorLabel\n storeId\n targetType\n targetId\n action\n beforeJson\n afterJson\n ipAddress\n userAgent\n createdAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminAuditLogsDocument, "\n query AdminAuditStorePicker($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n isActive\n }\n }\n }\n": types.AdminAuditStorePickerDocument, "\n query AdminAuditActorPicker($sellers: AdminSellerListInput, $admins: CursorInput) {\n adminSellers(input: $sellers) {\n items {\n accountId\n username\n name\n }\n }\n adminAdmins(input: $admins) {\n items {\n accountId\n username\n name\n }\n }\n }\n": types.AdminAuditActorPickerDocument, - "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersDocument, + "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersDocument, "\n query AdminBanner($bannerId: ID!) {\n adminBanner(bannerId: $bannerId) {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n }\n": types.AdminBannerDocument, "\n mutation AdminCreateBanner($input: AdminCreateBannerInput!) {\n adminCreateBanner(input: $input) {\n id\n }\n }\n": types.AdminCreateBannerDocument, "\n mutation AdminUpdateBanner($input: AdminUpdateBannerInput!) {\n adminUpdateBanner(input: $input) {\n id\n updatedAt\n }\n }\n": types.AdminUpdateBannerDocument, "\n mutation AdminDeleteBanner($bannerId: ID!) {\n adminDeleteBanner(bannerId: $bannerId)\n }\n": types.AdminDeleteBannerDocument, - "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersVisibleDocument, + "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n": types.AdminBannersVisibleDocument, "\n query AdminBannerProductOptions($input: AdminProductListInput) {\n adminProducts(input: $input) {\n items {\n id\n name\n storeName\n storeIsActive\n }\n }\n }\n": types.AdminBannerProductOptionsDocument, "\n query AdminBannerStoreOptions($input: AdminStoreListInput) {\n adminStores(input: $input) {\n items {\n id\n storeName\n }\n }\n }\n": types.AdminBannerStoreOptionsDocument, "\n query AdminBannerCategoryOptions($input: AdminCategoryListInput) {\n adminCategories(input: $input) {\n id\n name\n isActive\n }\n }\n": types.AdminBannerCategoryOptionsDocument, @@ -218,7 +218,7 @@ export function graphql(source: "\n query AdminAuditActorPicker($sellers: Admin /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersDocument; +export function graphql(source: "\n query AdminBanners($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n title\n imageUrl\n linkType\n linkUrl\n linkProductId\n linkStoreId\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n createdAt\n updatedAt\n }\n totalCount\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -238,7 +238,7 @@ export function graphql(source: "\n mutation AdminDeleteBanner($bannerId: ID!) /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersVisibleDocument; +export function graphql(source: "\n query AdminBannersVisible($input: AdminBannerListInput) {\n adminBanners(input: $input) {\n items {\n id\n placement\n linkCategoryId\n linkTargetAvailable\n startsAt\n endsAt\n sortOrder\n isActive\n }\n hasMore\n nextCursor\n }\n }\n"): typeof import('./graphql').AdminBannersVisibleDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/graphql/generated/graphql.ts b/src/graphql/generated/graphql.ts index 655c38b..3306926 100644 --- a/src/graphql/generated/graphql.ts +++ b/src/graphql/generated/graphql.ts @@ -873,7 +873,7 @@ export type AdminBannersQueryVariables = Exact<{ }>; -export type AdminBannersQuery = { adminBanners: { totalCount: number, hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, title: string | null, imageUrl: string, linkType: BannerLinkType, linkUrl: string | null, linkProductId: string | null, linkStoreId: string | null, linkCategoryId: string | null, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean, createdAt: string, updatedAt: string }> } }; +export type AdminBannersQuery = { adminBanners: { totalCount: number, hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, title: string | null, imageUrl: string, linkType: BannerLinkType, linkUrl: string | null, linkProductId: string | null, linkStoreId: string | null, linkCategoryId: string | null, linkTargetAvailable: boolean, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean, createdAt: string, updatedAt: string }> } }; export type AdminBannerQueryVariables = Exact<{ bannerId: string | number; @@ -908,7 +908,7 @@ export type AdminBannersVisibleQueryVariables = Exact<{ }>; -export type AdminBannersVisibleQuery = { adminBanners: { hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, linkCategoryId: string | null, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean }> } }; +export type AdminBannersVisibleQuery = { adminBanners: { hasMore: boolean, nextCursor: string | null, items: Array<{ id: string, placement: BannerPlacement, linkCategoryId: string | null, linkTargetAvailable: boolean, startsAt: string | null, endsAt: string | null, sortOrder: number, isActive: boolean }> } }; export type AdminBannerProductOptionsQueryVariables = Exact<{ input?: AdminProductListInput | null | undefined; @@ -1507,6 +1507,7 @@ export const AdminBannersDocument = new TypedDocumentString(` linkProductId linkStoreId linkCategoryId + linkTargetAvailable startsAt endsAt sortOrder @@ -1568,6 +1569,7 @@ export const AdminBannersVisibleDocument = new TypedDocumentString(` id placement linkCategoryId + linkTargetAvailable startsAt endsAt sortOrder From 0a536fa307205dd85629556da40d3694c4909645 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:59:56 +0900 Subject: [PATCH 08/14] =?UTF-8?q?chore:=20BE=20develop=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index db9aca2..0466de5 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@e76ccf91dfa38aa98acc2b4449e0e76610eb1610 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@251a957ea22bb84be535aa3d6cb8a4214e5e585b 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- From f0cf86b2c8a93e34cfd0161a6ad05d8d923197c7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:22:12 +0900 Subject: [PATCH 09/14] =?UTF-8?q?ci:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=A4=EB=94=A9=C2=B7=EC=9E=A1=20=EB=B3=91=EB=A0=AC=ED=99=94?= =?UTF-8?q?=C2=B7=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20CI?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check 한 잡 직렬(lint → typecheck → knip → test:cov → build) + coverage-report가 테스트를 한 번 더 돌리던 구조를 잡 분리 - lint / static(codegen:check → knip → build) / test 3샤드 / coverage-report / check 집계 - typecheck 단계 제거: build가 같은 tsc -b를 돌림 - codegen:check를 CI에 추가: 문서는 CI가 신선도를 본다고 했지만 실제로는 로컬 validate만 검사했음 - test: vitest --shard=i/3 + blob 리포터(blob-report/, 기본 .vitest/는 숨김이라 업로드에서 빠짐), 샤드별 임계는 CLI로 0 - coverage-report: blob 병합(--merge-reports --coverage)으로 vitest.config.ts 임계를 합친 결과에 적용 - always()로 돌고 샤드 결과가 success가 아니면 실패: 건너뜀이 필수 체크 통과로 잡히지 않게 - blob 개수가 SHARD_COUNT와 다르면 실패(부분 커버리지 방지) - develop·main push는 coverage-summary 아티팩트(보존 90일, continue-on-error) 업로드 - PR은 base 브랜치의 성공한 push 실행(같은 레포, base 커밋 우선)에서 받아 json-summary-compare-path로 비교. 없으면 비교 생략 - check: if: always(), needs 결과가 전부 success인지 jq로 검사(skipped·cancelled도 실패) - .gitignore에 .vitest/·blob-report/, 가이드·README의 CI·validate 설명 갱신 --- .github/workflows/pr-check.yml | 199 +++++++++++++++++++++---- .gitignore | 3 + README.md | 12 +- docs/guide/architecture-conventions.md | 7 +- 4 files changed, 187 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 2f7bae8..86eaaa3 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,13 +10,16 @@ permissions: contents: read concurrency: - # PR push는 같은 PR의 이전 실행을 취소, main/develop push는 커밋별 독립 실행 + # PR push는 같은 PR의 이전 실행을 취소, main/develop push는 커밋별 독립 실행(기준 커버리지 아티팩트 유실 방지) group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # test 잡 matrix.shard와 함께 바꾼다 — coverage-report가 blob 개수를 이 값과 대조한다 + SHARD_COUNT: 3 + jobs: - # 로컬 validate(pre-push)와 같은 순서. 보호된 check status라 --no-verify·훅 미설치를 우회하지 못한다 - check: + lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -38,42 +41,101 @@ jobs: - name: Lint run: pnpm lint - - name: Type check - run: pnpm typecheck - - - name: Dead code / unused deps (knip) - run: pnpm knip + # build가 tsc -b를 돌리므로 별도 typecheck 단계는 두지 않는다 + static: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Test with coverage - run: pnpm test:cov + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - # CODECOV_TOKEN이 레포 시크릿에 있을 때만 올린다 — 등록 전에는 건너뛰고, 등록 뒤 실패는 CI 실패로 드러낸다 - - name: Upload coverage to Codecov - if: env.CODECOV_TOKEN != '' - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + - name: Setup Node.js (24.x) & pnpm cache + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/lcov.info - disable_search: true - name: admin-fe-lcov - fail_ci_if_error: true + node-version: '24.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Codegen freshness + run: pnpm codegen:check + + - name: Dead code / unused deps (knip) + run: pnpm knip - name: Build run: | pnpm build test -f dist/index.html - # PR 코멘트에 커버리지 표. check와 별도 잡이라 필수 체크 이름이 안정적이다 + # 샤드는 커버리지 일부만 가져 임계를 0으로 끈다. 임계는 coverage-report가 합친 결과로 검사한다 + test: + name: test (${{ matrix.shard }}/${{ strategy.job-total }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node.js (24.x) & pnpm cache + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.x' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test shard with coverage + env: + SHARD: ${{ matrix.shard }} + run: >- + pnpm exec vitest run --coverage --shard="$SHARD/$SHARD_COUNT" + --reporter=default --reporter=blob --outputFile.blob="blob-report/blob-$SHARD.json" + --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 + --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 + + # 기본 경로(.vitest/blob)는 숨김 폴더라 upload-artifact가 건너뛴다 + - name: Upload blob report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-${{ matrix.shard }} + path: blob-report/ + retention-days: 1 + if-no-files-found: error + + # 필수 체크라 always()로 돈다 — needs 실패로 건너뛰면(skipped) 통과로 잡힌다 coverage-report: - if: github.event_name == 'pull_request' + needs: test + if: always() runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read pull-requests: write + actions: read steps: + - name: Require all test shards passed + env: + TEST_RESULT: ${{ needs.test.result }} + run: | + if [ "$TEST_RESULT" != "success" ]; then + echo "test 샤드 결과: $TEST_RESULT" + exit 1 + fi + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -89,12 +151,95 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Test with coverage - run: pnpm test:cov + - name: Download blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: blob-* + merge-multiple: true + path: blob-report + + # 빠진 샤드가 있으면 부분 커버리지가 된다. 임계(vitest.config.ts)는 합친 결과에 적용된다 + - name: Merge shard reports (coverage thresholds) + run: | + count=$(find blob-report -name 'blob-*.json' | wc -l) + if [ "$count" -ne "$SHARD_COUNT" ]; then + echo "blob ${count}개, 기대 ${SHARD_COUNT}개" + exit 1 + fi + pnpm exec vitest run --merge-reports=blob-report --coverage + + # develop·main push의 요약이 이후 PR의 비교 기준이 된다. 업로드 실패가 배포를 막지 않게 둔다 + - name: Upload base coverage (push) + if: github.event_name == 'push' + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-summary + path: | + coverage/coverage-summary.json + coverage/coverage-final.json + retention-days: 90 + if-no-files-found: error + + # CODECOV_TOKEN이 레포 시크릿에 있을 때만 올린다 — 등록 전에는 건너뛰고, 등록 뒤 실패는 CI 실패로 드러낸다 + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + disable_search: true + name: admin-fe-lcov + fail_ci_if_error: true + + # 기준은 이 레포 base 브랜치의 성공한 push 실행만 쓴다(PR 실행 아티팩트는 믿지 않음). base 커밋 실행 우선, 없으면 최신 + - name: Fetch base coverage (PR) + id: base + if: github.event_name == 'pull_request' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.base_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + run_id=$(gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?branch=$BASE_REF&event=push&status=success&per_page=50" \ + | jq -r --arg repo "$REPO" --arg ref "$BASE_REF" --arg sha "$BASE_SHA" ' + [.workflow_runs[] | select(.event == "push" and .head_branch == $ref and .head_repository.full_name == $repo)] + | (map(select(.head_sha == $sha)) + .)[0].id // empty') + if [ -z "$run_id" ]; then + echo "기준 실행 없음 — 비교 없이 리포트" + exit 0 + fi + echo "기준 실행: $run_id" + if gh run download "$run_id" -R "$REPO" -n coverage-summary -D base-coverage \ + && [ -f base-coverage/coverage-summary.json ]; then + echo "summary=base-coverage/coverage-summary.json" >> "$GITHUB_OUTPUT" + else + echo "기준 아티팩트 없음 — 비교 없이 리포트" + fi - - name: Coverage report (vitest) - if: always() + - name: Coverage report (PR comment) + if: github.event_name == 'pull_request' uses: davelosert/vitest-coverage-report-action@c4bbc33a89b7ace0e63d35f1f7d4bcee31155a73 # v2.13.0 + with: + json-summary-compare-path: ${{ steps.base.outputs.summary }} + + # 필수 체크 집계. 건너뜀·취소도 실패로 본다 — 보호된 status라 --no-verify·훅 미설치를 우회하지 못한다 + check: + if: always() + needs: [lint, static, test, coverage-report] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require all jobs succeeded + env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + echo "$NEEDS" | jq -e 'all(.[]; .result == "success")' > /dev/null pr-title: if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 96b739f..0c0cc11 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules /dist /coverage +# vitest blob(기본 .vitest/blob, CI 샤드는 blob-report/) +.vitest/ +blob-report/ *.tsbuildinfo .pnpm-store diff --git a/README.md b/README.md index d2bc932..510b36b 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ pnpm dev # http://localhost:5173 — /graphql·/auth는 localhost:400 ## 명령어 -| 명령 | 내용 | -| ----------------------------- | --------------------------------------------------------------------------- | -| `pnpm validate` | lint → typecheck → knip → 테스트(커버리지) → 빌드. pre-push 훅과 동일합니다 | -| `pnpm test` / `pnpm test:cov` | Vitest | -| `pnpm lint` / `pnpm format` | ESLint(경계 규칙 포함) / Prettier | -| `pnpm build` / `pnpm preview` | 운영 빌드 / 로컬 미리보기 | +| 명령 | 내용 | +| ----------------------------- | ------------------------------------------------------------------------------------------- | +| `pnpm validate` | lint → typecheck → codegen:check → knip → 테스트(커버리지) → 빌드. pre-push 훅과 동일합니다 | +| `pnpm test` / `pnpm test:cov` | Vitest | +| `pnpm lint` / `pnpm format` | ESLint(경계 규칙 포함) / Prettier | +| `pnpm build` / `pnpm preview` | 운영 빌드 / 로컬 미리보기 | ## 배포 diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 039fe81..249e2b0 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -67,7 +67,7 @@ src/ - `*.spec.ts(x)`를 소스 옆에. `it`은 한국어 평서형. - 네트워크는 MSW로 계약 기반 mock(응답 모양은 codegen 타입을 따른다). fetch를 직접 stub하지 않는다. -- 커버리지 임계는 `vitest.config.ts`. shadcn 복사본(`src/shared/ui`)·codegen 산출물·라우트 트리는 제외. +- 커버리지 임계는 `vitest.config.ts`. shadcn 복사본(`src/shared/ui`)·codegen 산출물·라우트 트리는 제외. CI는 샤드별 임계를 끄고 합친 결과로 검사한다(샤드 하나는 일부 커버리지만 가진다). ## 8. 명령어와 게이트 @@ -78,5 +78,10 @@ pnpm schema:pull [ref] # BE SDL 스냅샷 갱신(기본 main). BE_DIR=../caqui pnpm codegen # 스냅샷 + 문서 → src/graphql/generated (커밋 대상, CI가 codegen:check로 신선도 검사) ``` +- CI(`pr-check.yml`)는 잡을 나눠 병렬로 돈다. 필수 체크 `check`는 아래 잡이 모두 `success`인지 집계한다(건너뜀·취소도 실패). + - `lint` + - `static`: `codegen:check` → `knip` → `build`(`tsc -b` 포함이라 typecheck 단계는 따로 없다) + - `test`: Vitest 3샤드, 결과는 blob 아티팩트로 넘긴다 + - `coverage-report`: blob을 합쳐 임계 검사 → Codecov → PR 댓글. 비교 기준은 base 브랜치(develop·main) push 실행이 올린 커버리지 요약 - 커밋은 Conventional Commits + 한국어 본문(commitlint). 브랜치는 `/<대상>`. - PR 본문에 `## 플랜 대조` 표. 봇 리뷰(Codex·CodeRabbit)는 BE와 같은 절차로 처리한다. From 9c2564f830a478e3de68e0493ca4ef963b422962 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:26:57 +0900 Subject: [PATCH 10/14] =?UTF-8?q?test:=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9E=84=EA=B3=84=EB=A5=BC=20=EC=8B=A4=EC=B8=A1=20?= =?UTF-8?q?=EC=A0=95=EC=88=98=20=EB=82=B4=EB=A6=BC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=83=81=ED=96=A5=20(95/88/94/96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - statements 80→95, branches 70→88, functions 80→94, lines 80→96 - 근거: PR CI 37220367732의 샤드 합산 95.54/88.07/94.29/96.07 = 같은 트리 단일 실행(로컬 validate)과 일치 - 임계는 리터럴 유지(리포트 액션이 vitest.config.ts에서 정규식으로 읽음) - 결정 기록 D16 추가, D7 임계는 대체됨 표시 --- docs/guide/decisions.md | 3 ++- vitest.config.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/decisions.md b/docs/guide/decisions.md index a93b412..f5b6272 100644 --- a/docs/guide/decisions.md +++ b/docs/guide/decisions.md @@ -10,7 +10,7 @@ | D4 | 2026-09-27 | TanStack Query + graphql-request + graphql-codegen(client-preset) | 정규화 캐시 없이 invalidate로 충분한 CRUD 화면. 서버 진실 우선, 낙관적 업데이트 없음 | | D5 | 2026-09-27 | Tailwind v4 + shadcn/ui(new-york, neutral) + TanStack Table + react-hook-form + zod, 차트 Recharts | FE-v2와 같은 스타일 체계. 컴포넌트는 레포가 소유(`src/shared/ui`) | | D6 | 2026-09-27 | 디자인 토큰은 FE-v2 계승(primary #7c5cff 계열, gray 스케일, status 3색, radius 10px, Pretendard), 라이트/다크 | 서비스와 같은 브랜드 인상, 장시간 사용 도구라 다크 필요 | -| D7 | 2026-09-27 | Vitest + Testing Library + MSW. E2E 없음. 커버리지 lines/statements 80 · branches 70 · functions 80, Codecov patch 80 | 관리자 도구 범위에 맞는 비용. shadcn 복사본은 커버리지 제외 | +| D7 | 2026-09-27 | Vitest + Testing Library + MSW. E2E 없음. 커버리지 lines/statements 80 · branches 70 · functions 80(대체됨 → D16), Codecov patch 80 | 관리자 도구 범위에 맞는 비용. shadcn 복사본은 커버리지 제외 | | D8 | 2026-09-27 | 배포: 이 레포가 Dockerfile(nginx) · `infra/compose.yml` · `deploy.yml` 소유. GHCR `ghcr.io/caquick/caquick-admin-fe:`(arm64), 맥미니 셀프호스트 러너, BE compose 네트워크 `caquick_default`에 external 참여, 기존 cloudflared가 라우팅 | BE와 독립 배포·롤백. BE 레포 변경은 Tunnel 호스트 1줄 | | D9 | 2026-09-27 | 도메인 `admin.caquick.site` → 컨테이너 `caquick-admin:80`. API `https://api.caquick.site` | 같은 부모 도메인이라 refresh 쿠키가 same-site로 전송된다 — 쿠키 도메인 변경 불필요, BE는 CORS 오리진만 추가 | | D10 | 2026-09-27 | 인증: REST `/auth/admin/*`. accessToken은 메모리, refresh는 httpOnly 쿠키. 401이면 refresh 1회 후 재시도, 부팅 시 refresh로 복원, `mustChangePassword`면 변경 화면 강제 | 토큰을 localStorage에 두지 않는다(XSS 반경). 로컬은 Vite 프록시로 same-origin | @@ -19,3 +19,4 @@ | D13 | 2026-09-27 | BE SDL 스냅샷(`schema/schema.graphql`) 커밋 + `pnpm schema:pull`. CI는 codegen 신선도만 게이트, BE main과의 drift는 advisory | CI가 BE 체크아웃에 의존하지 않게 | | D14 | 2026-09-27 | 문서: `CLAUDE.md`·`.claude/`·`AGENTS.md`·`docs/*`(guide 제외)·`.figma/` gitignore. `docs/guide/`·README 커밋 | BE와 같은 방식 | | D15 | 2026-09-27 | 의존 방향은 ESLint boundaries로 강제(shared→features 금지, feature 간은 index.ts만). 검사기는 반증 케이스로 확인하고 넣는다 | BE `arch:check`와 같은 역할 | +| D16 | 2026-10-05 | 커버리지 임계를 실측 정수 내림으로 상향: statements 95 · branches 88 · functions 94 · lines 96. CI는 테스트를 3샤드로 나누고 coverage-report가 합친 결과로 검사 | 실측(95.54·88.07·94.29·96.07)보다 한참 낮은 임계는 회귀를 못 잡는다. 샤드 합산이 단일 실행과 같음을 확인 | diff --git a/vitest.config.ts b/vitest.config.ts index 77638ee..68098c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,8 @@ export default defineConfig({ 'src/main.tsx', 'src/**/*.d.ts', ], - thresholds: { lines: 80, statements: 80, branches: 70, functions: 80 }, + // 실측(샤드 합산 = 단일 실행)의 정수 내림. CI는 coverage-report가 합친 결과로 검사한다 + thresholds: { lines: 96, statements: 95, branches: 88, functions: 94 }, }, }, }); From 22fbb0e444ad9bf3cfafc5ea31695de6e8a9c14a Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:31:48 +0900 Subject: [PATCH 11/14] =?UTF-8?q?test:=20=EB=B0=98=EC=A6=9D=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EC=9E=A1=20=ED=99=95=EC=9D=B8=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=20=EC=98=88=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI(GITHUB_ACTIONS)에서만 실패하는 테스트 1건: 샤드 실패 시 coverage-report·check가 실패로 끝나는지(건너뜀·성공 아님) 확인용 - 로컬 pre-push는 통과(훅 우회 없이 push) - 확인 뒤 revert --- src/test/infra/ci-falsification.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/test/infra/ci-falsification.spec.ts diff --git a/src/test/infra/ci-falsification.spec.ts b/src/test/infra/ci-falsification.spec.ts new file mode 100644 index 0000000..8e15250 --- /dev/null +++ b/src/test/infra/ci-falsification.spec.ts @@ -0,0 +1,5 @@ +// 반증: 집계 잡 확인용 — 되돌림. CI(GITHUB_ACTIONS)에서만 실패해 로컬 pre-push는 통과한다 +it('반증: 집계 잡 확인용 — 되돌림', () => { + const ci: unknown = import.meta.env.GITHUB_ACTIONS; + expect(ci).toBeUndefined(); +}); From 8f1fa0a183b2142fb1f3f1d1f801ab3efebe6473 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 02:35:22 +0900 Subject: [PATCH 12/14] =?UTF-8?q?revert:=20=EB=B0=98=EC=A6=9D=20=E2=80=94?= =?UTF-8?q?=20=EC=A7=91=EA=B3=84=20=EC=9E=A1=20=ED=99=95=EC=9D=B8=EC=9A=A9?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 22fbb0e 되돌림. CI 37220956009에서 test (1/3)·coverage-report·check 모두 failure 확인 --- src/test/infra/ci-falsification.spec.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/test/infra/ci-falsification.spec.ts diff --git a/src/test/infra/ci-falsification.spec.ts b/src/test/infra/ci-falsification.spec.ts deleted file mode 100644 index 8e15250..0000000 --- a/src/test/infra/ci-falsification.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 반증: 집계 잡 확인용 — 되돌림. CI(GITHUB_ACTIONS)에서만 실패해 로컬 pre-push는 통과한다 -it('반증: 집계 잡 확인용 — 되돌림', () => { - const ci: unknown = import.meta.env.GITHUB_ACTIONS; - expect(ci).toBeUndefined(); -}); From da44fd5d4b3092817b58b2221cdffe93ba9037a7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 03:21:31 +0900 Subject: [PATCH 13/14] =?UTF-8?q?ci:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=83=A4=EB=93=9C=20=EC=9E=AC=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=B6=A9=EB=8F=8C=C2=B7=EC=9E=84=EA=B3=84=20?= =?UTF-8?q?=EB=AF=B8=EB=8B=AC=20=EB=95=8C=20=EB=8C=93=EA=B8=80=C2=B7?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=9B=84=EB=B3=B4=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blob 업로드 overwrite: 실패한 샤드도 올리므로 재실행 때 같은 이름 충돌로 빨간불이 이어지던 것(main이면 배포 막힘), 보존 1일 → 7일 - 기준 조회·PR 댓글을 !cancelled() + 요약 파일 존재 조건으로: 임계 미달로 병합이 실패해도 댓글을 갱신(이전 초록 댓글이 남지 않게) - 기준 아티팩트: base 커밋 실행 우선, 최신 순으로 최대 5개 실행을 다운로드될 때까지 시도(Codex P2) - check(집계) permissions: {}, pre-push 훅 주석을 새 CI 구조에 맞게 --- .github/workflows/pr-check.yml | 39 +++++++++++++++++++--------------- .husky/pre-push | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 86eaaa3..15c172d 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -113,7 +113,9 @@ jobs: with: name: blob-${{ matrix.shard }} path: blob-report/ - retention-days: 1 + # 실패한 샤드도 올리므로 재실행 때 같은 이름이 생긴다(덮어쓰지 않으면 업로드 실패로 빨간불이 이어짐) + overwrite: true + retention-days: 7 if-no-files-found: error # 필수 체크라 always()로 돈다 — needs 실패로 건너뛰면(skipped) 통과로 잡힌다 @@ -178,6 +180,7 @@ jobs: path: | coverage/coverage-summary.json coverage/coverage-final.json + overwrite: true retention-days: 90 if-no-files-found: error @@ -194,10 +197,11 @@ jobs: name: admin-fe-lcov fail_ci_if_error: true - # 기준은 이 레포 base 브랜치의 성공한 push 실행만 쓴다(PR 실행 아티팩트는 믿지 않음). base 커밋 실행 우선, 없으면 최신 + # 기준은 이 레포 base 브랜치의 성공한 push 실행만 쓴다(PR 실행 아티팩트는 믿지 않음). base 커밋 실행 우선, 그다음 최신 순으로 + # 아티팩트가 있는 실행을 최대 5개까지 찾는다. 임계 미달로 병합이 실패해도 요약이 있으면 댓글을 갱신한다 - name: Fetch base coverage (PR) id: base - if: github.event_name == 'pull_request' + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/coverage-summary.json') != '' }} continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -205,24 +209,24 @@ jobs: BASE_REF: ${{ github.base_ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - run_id=$(gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?branch=$BASE_REF&event=push&status=success&per_page=50" \ + run_ids=$(gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?branch=$BASE_REF&event=push&status=success&per_page=50" \ | jq -r --arg repo "$REPO" --arg ref "$BASE_REF" --arg sha "$BASE_SHA" ' [.workflow_runs[] | select(.event == "push" and .head_branch == $ref and .head_repository.full_name == $repo)] - | (map(select(.head_sha == $sha)) + .)[0].id // empty') - if [ -z "$run_id" ]; then - echo "기준 실행 없음 — 비교 없이 리포트" - exit 0 - fi - echo "기준 실행: $run_id" - if gh run download "$run_id" -R "$REPO" -n coverage-summary -D base-coverage \ - && [ -f base-coverage/coverage-summary.json ]; then - echo "summary=base-coverage/coverage-summary.json" >> "$GITHUB_OUTPUT" - else - echo "기준 아티팩트 없음 — 비교 없이 리포트" - fi + | (map(select(.head_sha == $sha)) + .) | map(.id) + | reduce .[] as $id ([]; if any(.[]; . == $id) then . else . + [$id] end) + | .[:5][]') + for run_id in $run_ids; do + if gh run download "$run_id" -R "$REPO" -n coverage-summary -D base-coverage \ + && [ -f base-coverage/coverage-summary.json ]; then + echo "기준 실행: $run_id" + echo "summary=base-coverage/coverage-summary.json" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "기준 아티팩트 없음 — 비교 없이 리포트" - name: Coverage report (PR comment) - if: github.event_name == 'pull_request' + if: ${{ !cancelled() && github.event_name == 'pull_request' && hashFiles('coverage/coverage-summary.json') != '' }} uses: davelosert/vitest-coverage-report-action@c4bbc33a89b7ace0e63d35f1f7d4bcee31155a73 # v2.13.0 with: json-summary-compare-path: ${{ steps.base.outputs.summary }} @@ -233,6 +237,7 @@ jobs: needs: [lint, static, test, coverage-report] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: {} steps: - name: Require all jobs succeeded env: diff --git a/.husky/pre-push b/.husky/pre-push index 56bd684..3f2eb5f 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,2 @@ -# push 전 로컬 검증 — CI check와 같은 순서. --no-verify 금지 +# push 전 로컬 검증 — CI의 lint·static·test·coverage-report가 나눠 하는 검사를 한 번에. --no-verify 금지 pnpm validate From 72a6699e910324b943eed6aca78b95684b6809bf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 03:21:59 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20BE=20develop=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index 0466de5..36852f3 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@251a957ea22bb84be535aa3d6cb8a4214e5e585b 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@746d6fa87c2438aeef9afd739885f33274e1f021 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ----