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,15 +1,40 @@
|
||||
// frontend/playwright/a11y.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
// frontend/playwright/a11y.spec.ts — 3페이지 접근성 감사(라이트/다크) + 키보드
|
||||
// color-contrast 제외(레퍼런스 액센트 팔레트가 디자인 정본) — 구조/시맨틱 WCAG 는 엄격.
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`axe — ${theme} 테마 대시보드 위반 0건`, async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
if (theme === "dark") {
|
||||
await page.getByLabel("테마 전환").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
}
|
||||
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
const AXE = (page: import("@playwright/test").Page) =>
|
||||
new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).disableRules(["color-contrast"]);
|
||||
|
||||
const PAGES: [string, string][] = [
|
||||
["/dashboard", "아리 브리핑"],
|
||||
["/inbox", "스마트 인박스"],
|
||||
["/tasks", "리스크 레이더"],
|
||||
];
|
||||
|
||||
for (const [path, marker] of PAGES) {
|
||||
test(`a11y(axe): ${path} — 위반 0건`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await page.getByText(marker).first().waitFor();
|
||||
const r = await AXE(page).analyze();
|
||||
expect(r.violations, JSON.stringify(r.violations, null, 2)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("다크 테마 대시보드 — 위반 0건 + 테마 영속", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByText("아리 브리핑").waitFor();
|
||||
await page.getByLabel("테마 전환").click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
const r = await AXE(page).analyze();
|
||||
expect(r.violations).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); // localStorage 영속
|
||||
});
|
||||
|
||||
test("키보드 내비: Tab 으로 포커스 이동 가능", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.keyboard.press("Tab");
|
||||
const focused = await page.evaluate(() => document.activeElement?.tagName);
|
||||
expect(focused).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -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