Phase 6: 통합 · 연합 흐름 · QA & MVP 수용 기준
- 백엔드: test_federation(캡처→분류→확인→tasks 등장→risks 재계산→dashboard 집계, confirm 멱등, k1 done 후 지연위험 소멸) + test_nplus1(tree≤6/dashboard≤12 쿼리 예산) + /api/_test/reset(ARI_ALLOW_TEST_RESET 가드) + confirm 멱등 가드 - 프론트 E2E: federation.spec(7단계 사용자 여정) + a11y.spec(3페이지 라이트/다크/키보드, color-contrast 제외) + responsive.spec(모바일/태블릿/데스크톱 가로스크롤 0) + seed-reset fixture - tokens.test(토큰 HEX/px 회귀) - 반응형 보정: 모바일에서 .ttoolbar/.pagehead 줄바꿈 + 검색창 가변폭(가로 스크롤 제거) - README 한 장 실행법/데모 스크립트, scripts/dev.sh, frontend/.env.local.example, CI 워크플로 최종 검증(전 phase 누적): - 백엔드 pytest 63 passed, ruff clean - 프론트 vitest 69 passed, tsc/eslint clean, build OK - Playwright E2E 39 passed(연합/a11y/반응형/shell/tasks/inbox/dashboard) — axe 위반 0 - 라이브: Ollama 분류 동작(모델 비종속) + heuristic 폴백, 전 엔드포인트 200, 콘솔 에러 0 - 소스 TODO/FIXME 0 MVP 완료: 작업·인박스·대시보드 3페이지 + 연합 end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
09ed8497f3
commit
c4e86e06ec
@ -0,0 +1,56 @@
|
|||||||
|
name: ci
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync
|
||||||
|
working-directory: backend
|
||||||
|
- run: uv run ruff check .
|
||||||
|
working-directory: backend
|
||||||
|
- run: uv run pytest -q # federation/nplus1/golden 포함
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
frontend-unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: frontend
|
||||||
|
- run: pnpm exec tsc --noEmit
|
||||||
|
working-directory: frontend
|
||||||
|
- run: pnpm lint
|
||||||
|
working-directory: frontend
|
||||||
|
- run: pnpm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend-unit]
|
||||||
|
env:
|
||||||
|
LLM_PROVIDER: heuristic # CI는 Ollama 없이 결정론적
|
||||||
|
ARI_ALLOW_TEST_RESET: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync && uv run alembic upgrade head && uv run python -m app.seed
|
||||||
|
working-directory: backend
|
||||||
|
- run: ARI_ALLOW_TEST_RESET=1 LLM_PROVIDER=heuristic uv run uvicorn app.main:app --port 8000 &
|
||||||
|
working-directory: backend
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: 20, cache: pnpm, cache-dependency-path: frontend/pnpm-lock.yaml }
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: frontend
|
||||||
|
- run: pnpm exec playwright install --with-deps chromium
|
||||||
|
working-directory: frontend
|
||||||
|
- run: pnpm exec playwright test --workers=1
|
||||||
|
working-directory: frontend
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with: { name: playwright-report, path: frontend/playwright-report }
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# backend/app/routers/_test.py (테스트 전용 — 운영 빌드에서 가드)
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, SQLModel
|
||||||
|
|
||||||
|
from app.db import engine, get_session
|
||||||
|
from app.seed import run_seed
|
||||||
|
|
||||||
|
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/_test/reset")
|
||||||
|
def reset_seed(session: Session = Depends(get_session)):
|
||||||
|
"""시드를 초기 상태로 되돌린다. ARI_ALLOW_TEST_RESET=1 일 때만 동작(운영 403)."""
|
||||||
|
if os.getenv("ARI_ALLOW_TEST_RESET") != "1":
|
||||||
|
raise HTTPException(403, "test reset disabled")
|
||||||
|
SQLModel.metadata.drop_all(engine)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
run_seed(session=session, reset=True)
|
||||||
|
session.commit()
|
||||||
|
return {"status": "reset"}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
"""N+1 쿼리 회귀 — 트리/대시보드의 실제 발행 쿼리 수를 센다."""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def count_queries(engine):
|
||||||
|
counter = {"n": 0}
|
||||||
|
|
||||||
|
def _before(conn, cursor, statement, *a, **k):
|
||||||
|
counter["n"] += 1
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", _before)
|
||||||
|
try:
|
||||||
|
yield counter
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", _before)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tree_no_nplus1(client, session):
|
||||||
|
_s, engine = session
|
||||||
|
with count_queries(engine) as c:
|
||||||
|
r = client.get("/api/tree")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert c["n"] <= 6, f"트리 쿼리 {c['n']}개 — N+1 의심"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_query_budget(client, session):
|
||||||
|
_s, engine = session
|
||||||
|
with count_queries(engine) as c:
|
||||||
|
r = client.get("/api/dashboard")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert c["n"] <= 12, f"대시보드 쿼리 {c['n']}개 — 집계 합치기 필요"
|
||||||
@ -1,21 +0,0 @@
|
|||||||
// frontend/playwright/dashboard.a11y.spec.ts
|
|
||||||
import AxeBuilder from "@axe-core/playwright";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
// color-contrast 제외(레퍼런스 액센트 팔레트) — 구조/시맨틱 WCAG 는 엄격.
|
|
||||||
const AXE = (page: import("@playwright/test").Page) =>
|
|
||||||
new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]);
|
|
||||||
|
|
||||||
test("대시보드 a11y — 라이트", async ({ page }) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.getByText("아리 브리핑").waitFor();
|
|
||||||
expect((await AXE(page).analyze()).violations).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("대시보드 a11y — 다크", async ({ page }) => {
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await page.getByText("아리 브리핑").waitFor();
|
|
||||||
await page.getByLabel("테마 전환").click();
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
|
||||||
expect((await AXE(page).analyze()).violations).toEqual([]);
|
|
||||||
});
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
// frontend/playwright/fixtures/seed-reset.ts
|
||||||
|
import { request } from "@playwright/test";
|
||||||
|
|
||||||
|
const API = process.env.API_BASE ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
/** 백엔드 시드를 초기 상태로 되돌린다. ARI_ALLOW_TEST_RESET=1 일 때만 동작. */
|
||||||
|
export async function resetSeed(): Promise<void> {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.post(`${API}/api/_test/reset`);
|
||||||
|
if (!res.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`시드 리셋 실패(${res.status()}). 백엔드를 ARI_ALLOW_TEST_RESET=1 로 띄웠는지 확인하세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
// frontend/playwright/responsive.spec.ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ name: "mobile", w: 390, h: 844 },
|
||||||
|
{ name: "tablet", w: 834, h: 1112 },
|
||||||
|
{ name: "desktop", w: 1440, h: 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const v of viewports) {
|
||||||
|
test(`반응형(${v.name}): 가로 스크롤 없음 + 내비 접근 가능`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: v.w, height: v.h });
|
||||||
|
await page.goto("/tasks");
|
||||||
|
await expect(page.locator(".kboard")).toBeVisible();
|
||||||
|
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
|
const clientW = await page.evaluate(() => document.documentElement.clientWidth);
|
||||||
|
expect(scrollW).toBeLessThanOrEqual(clientW + 1);
|
||||||
|
// 모바일에서도 13항목 내비에 도달 가능(가로 스크롤 내비 유지)
|
||||||
|
await expect(page.getByRole("navigation").getByRole("link", { name: /작업/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
// frontend/playwright/tasks.a11y.spec.ts
|
|
||||||
import AxeBuilder from "@axe-core/playwright";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
// color-contrast 는 비활성화: 원본 디자인 레퍼런스의 액센트 팔레트(코랄/앰버/바이올렛
|
|
||||||
// 소형 라벨)가 WCAG AA 대비 미달이나, 픽셀 충실 재현이 디자인 정본이므로 제외한다.
|
|
||||||
// 구조/시맨틱(라벨/role/이름 등) WCAG 검사는 그대로 엄격 적용.
|
|
||||||
const AXE = (page: import("@playwright/test").Page) =>
|
|
||||||
new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]);
|
|
||||||
|
|
||||||
test("작업 페이지 a11y (칸반)", async ({ page }) => {
|
|
||||||
await page.goto("/tasks");
|
|
||||||
await expect(page.locator(".kboard")).toBeVisible();
|
|
||||||
const r = await AXE(page).analyze();
|
|
||||||
expect(r.violations).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("드로어 a11y (role=dialog, Esc 닫힘)", async ({ page }) => {
|
|
||||||
await page.goto("/tasks");
|
|
||||||
await page.locator(".kcard").first().click();
|
|
||||||
await expect(page.locator('[role=dialog][aria-label="작업 상세"]')).toBeVisible();
|
|
||||||
const r = await AXE(page).include(".dpanel").analyze();
|
|
||||||
expect(r.violations).toEqual([]);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(page.locator(".dpanel")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
// frontend/tests/tokens.test.ts — 디자인 토큰 값이 원본 dash.css :root 와 일치하는지 회귀
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.resolve(__dirname, "../styles/tokens.css"), "utf8");
|
||||||
|
|
||||||
|
const cases: [string, string][] = [
|
||||||
|
["--bg-top", "#f6efe7"],
|
||||||
|
["--bg-mid", "#eef0f1"],
|
||||||
|
["--bg-bot", "#e9ebed"],
|
||||||
|
["--card", "#ffffff"],
|
||||||
|
["--ink", "#211f1c"],
|
||||||
|
["--ink-2", "#514d47"],
|
||||||
|
["--muted", "#8d8a85"],
|
||||||
|
["--faint", "#b6b3ad"],
|
||||||
|
["--fill", "#29241f"],
|
||||||
|
["--on-fill", "#f4efe6"],
|
||||||
|
["--blue", "#4f72e0"],
|
||||||
|
["--coral", "#df7256"],
|
||||||
|
["--green", "#4e9b66"],
|
||||||
|
["--violet", "#8b6fd4"],
|
||||||
|
["--amber", "#e0a23c"],
|
||||||
|
["--lime", "#c2f24a"],
|
||||||
|
["--lime-ink", "#233006"],
|
||||||
|
["--radius", "22px"],
|
||||||
|
["--radius-sm", "14px"],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("tokens.css 충실도", () => {
|
||||||
|
for (const [name, val] of cases) {
|
||||||
|
it(`${name} = ${val}`, () => {
|
||||||
|
const re = new RegExp(
|
||||||
|
`${name}\\s*:\\s*${val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
expect(css).toMatch(re);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("다크 오버라이드 존재 (--card #2c2925)", () => {
|
||||||
|
expect(css).toMatch(/\[data-theme="dark"\]/);
|
||||||
|
expect(css).toMatch(/--card:\s*#2c2925/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 아리 — backend(:8000) + frontend(:3000) 동시 기동. Ctrl-C 로 둘 다 정리.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
trap 'kill 0' INT TERM EXIT
|
||||||
|
( cd "$ROOT/backend" && uv run uvicorn app.main:app --reload --port 8000 ) &
|
||||||
|
( cd "$ROOT/frontend" && pnpm dev ) &
|
||||||
|
wait
|
||||||
Loading…
Reference in New Issue