전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
207
apps/web/scripts/check-design-ssot.mjs
Normal file
207
apps/web/scripts/check-design-ssot.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function forbid(relativePath, source, pattern, message) {
|
||||
const match = source.match(pattern);
|
||||
if (match)
|
||||
failures.push(`${relativePath}: ${message} (${JSON.stringify(match[0])})`);
|
||||
}
|
||||
|
||||
function countRawColors(source) {
|
||||
return source.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/gi)?.length ?? 0;
|
||||
}
|
||||
|
||||
const shellPath = "src/components/shell/shell.css";
|
||||
const surfacePath = "src/components/ui/ui.css";
|
||||
const settingsPath = "src/pages/settings/settings.css";
|
||||
const sessionPath = "src/pages/Session.tsx";
|
||||
const learnerHomePath = "src/pages/LearnerHome.tsx";
|
||||
const personaViewModelPath = "src/lib/personaViewModel.ts";
|
||||
const appPath = "src/App.tsx";
|
||||
const designDocPath = "../../docs/DESIGN_CONCEPT.md";
|
||||
const shell = await read(shellPath);
|
||||
const surface = await read(surfacePath);
|
||||
const settings = await read(settingsPath);
|
||||
const session = await read(sessionPath);
|
||||
const learnerHome = await read(learnerHomePath);
|
||||
const personaViewModel = await read(personaViewModelPath);
|
||||
const app = await read(appPath);
|
||||
const designDoc = await read(designDocPath);
|
||||
|
||||
// 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역
|
||||
// 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가
|
||||
// 무제한 확산되는 것을 막는다. 공통 ui/shell은 아래 별도 규칙으로 0개를 강제한다.
|
||||
const rawColorBudgets = {
|
||||
"src/pages/session/session.css": 216,
|
||||
"src/pages/session-review/session-review.css": 29,
|
||||
"src/pages/learner-home.css": 25,
|
||||
"src/components/auth/auth-shell.css": 20,
|
||||
"src/pages/login/login.css": 11,
|
||||
"src/components/avatar/client-avatar.css": 7,
|
||||
"src/pages/avatar-expression-lab.css": 7,
|
||||
"src/pages/avatar-preview.css": 5,
|
||||
"src/pages/pending-approval.css": 3,
|
||||
};
|
||||
|
||||
for (const [relativePath, budget] of Object.entries(rawColorBudgets)) {
|
||||
const count = countRawColors(await read(relativePath));
|
||||
if (count > budget) {
|
||||
failures.push(
|
||||
`${relativePath}: 국소 raw color 예산 초과 (${count}/${budget}). 기존 의미 토큰 또는 페이지 예외 토큰으로 묶어야 합니다`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
forbid(
|
||||
shellPath,
|
||||
shell,
|
||||
/\.(?:lh-|sr-|pf-|ad-|ps-|sx-|vg-set__)/,
|
||||
"AppShell SSOT가 페이지 전용 클래스를 스타일하면 안 됩니다",
|
||||
);
|
||||
forbid(
|
||||
appPath,
|
||||
app,
|
||||
/^import\s+\w+\s+from\s+["']\.\/pages\//m,
|
||||
"역할별 페이지는 초기 번들에 정적 import하면 안 됩니다",
|
||||
);
|
||||
forbid(
|
||||
appPath,
|
||||
app,
|
||||
/minHeight:\s*["']100vh["']/,
|
||||
"전체 높이 화면은 모바일 viewport 안정성을 위해 100dvh를 사용해야 합니다",
|
||||
);
|
||||
forbid(
|
||||
shellPath,
|
||||
shell,
|
||||
/!important/,
|
||||
"공통 셸 권위는 !important가 아니라 소유권으로 유지해야 합니다",
|
||||
);
|
||||
forbid(
|
||||
surfacePath,
|
||||
surface,
|
||||
/!important/,
|
||||
"Surface primitive는 !important에 의존하면 안 됩니다",
|
||||
);
|
||||
forbid(
|
||||
surfacePath,
|
||||
surface,
|
||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||
"공통 UI 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
|
||||
);
|
||||
forbid(
|
||||
shellPath,
|
||||
shell,
|
||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||
"공통 셸 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
|
||||
);
|
||||
forbid(
|
||||
sessionPath,
|
||||
session,
|
||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||
"세션 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
|
||||
);
|
||||
forbid(
|
||||
learnerHomePath,
|
||||
learnerHome,
|
||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||
"학습자 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
|
||||
);
|
||||
forbid(
|
||||
settingsPath,
|
||||
settings,
|
||||
/body\[data-page=["']settings["']\][^{]*(?:\.vg-topbar|\.vg-nav|\.vg-main|\.vg-shell)/,
|
||||
"설정 페이지 CSS가 공통 앱 크롬을 재정의하면 안 됩니다",
|
||||
);
|
||||
|
||||
for (const required of [
|
||||
".vg-surface.vg-surface--panel",
|
||||
".vg-surface.vg-surface--inset",
|
||||
".vg-surface.vg-surface--interactive",
|
||||
"var(--glass-surface)",
|
||||
"var(--glass-border)",
|
||||
]) {
|
||||
if (!surface.includes(required)) {
|
||||
failures.push(`${surfacePath}: Surface SSOT 필수 계약 누락 (${required})`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"PENDING_PERSONA_AVATAR_APPEARANCE",
|
||||
"personaAvatarAppearance",
|
||||
"personaBaselineExpression",
|
||||
]) {
|
||||
if (!personaViewModel.includes(required)) {
|
||||
failures.push(
|
||||
`${personaViewModelPath}: 페르소나 시각 SSOT 필수 계약 누락 (${required})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"### 0.3 구현 소유권 계약",
|
||||
"DESIGN_VARIANCE 4 / MOTION_INTENSITY 3 / VISUAL_DENSITY 6",
|
||||
"apps/web/src/styles/tokens.css",
|
||||
"apps/web/src/components/ui/",
|
||||
"apps/web/src/components/shell/",
|
||||
]) {
|
||||
if (!designDoc.includes(required)) {
|
||||
failures.push(
|
||||
`${designDocPath}: 디자인 방법론 SSOT 필수 계약 누락 (${required})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of [
|
||||
"Login",
|
||||
"Onboarding",
|
||||
"PendingApproval",
|
||||
"LearnerHome",
|
||||
"AvatarExpressionLab",
|
||||
"AvatarPreview",
|
||||
"Session",
|
||||
"SessionReview",
|
||||
"Professor",
|
||||
"PersonaStudio",
|
||||
"Admin",
|
||||
"AdminAi",
|
||||
"Settings",
|
||||
]) {
|
||||
if (!app.includes(`lazy(() => import("./pages/${page}"))`)) {
|
||||
failures.push(`${appPath}: 라우트 지연 로딩 계약 누락 (${page})`);
|
||||
}
|
||||
}
|
||||
if (!app.includes("<Suspense fallback={<BootScreen />}>")) {
|
||||
failures.push(
|
||||
`${appPath}: 지연 라우트의 공통 Suspense 로딩 경계가 필요합니다`,
|
||||
);
|
||||
}
|
||||
|
||||
const legacyThemeHook = path.join(root, "src/pages/settings/useTheme.ts");
|
||||
if (
|
||||
await fs
|
||||
.stat(legacyThemeHook)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
failures.push(
|
||||
"src/pages/settings/useTheme.ts: 페이지 전용 테마 store를 다시 만들면 안 됩니다",
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
"디자인 SSOT 검사 실패\n" + failures.map((item) => `- ${item}`).join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"디자인 SSOT 검사 통과: 방법론, AppShell, Theme, Surface, route bundle 소유권이 분리되어 있습니다.",
|
||||
);
|
||||
56
apps/web/scripts/run-e2e.mjs
Normal file
56
apps/web/scripts/run-e2e.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const conflicting = extraArgs.find(
|
||||
(arg) =>
|
||||
arg === "--project" ||
|
||||
arg.startsWith("--project=") ||
|
||||
arg === "--workers" ||
|
||||
arg.startsWith("--workers="),
|
||||
);
|
||||
|
||||
if (conflicting) {
|
||||
console.error(
|
||||
`e2e 전체 러너는 프로젝트와 worker를 단계별로 소유합니다 (${conflicting}). ` +
|
||||
"focused 실행은 npm run e2e:parallel 또는 npm run e2e:single-run을 사용하세요.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const playwrightCli = path.resolve(
|
||||
process.cwd(),
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
);
|
||||
|
||||
function runPhase(label, args) {
|
||||
console.log(`\n[e2e] ${label}`);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[playwrightCli, "test", ...args, ...extraArgs],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
console.error(`[e2e] ${label} 실행 실패: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Route-fixture/browser-only tests may use the machine's worker pool. Durable DB,
|
||||
// provider, and engine tests must start only after that pool drains and then run
|
||||
// one at a time against the single local API/gateway runtime.
|
||||
runPhase("fixture desktop/mobile 병렬 단계", [
|
||||
"--project=chromium-desktop",
|
||||
"--project=chromium-mobile",
|
||||
`--workers=${process.env.E2E_PARALLEL_WORKERS ?? "4"}`,
|
||||
]);
|
||||
runPhase("DB/engine single-run 직렬 단계", [
|
||||
"--project=chromium-single-run",
|
||||
"--workers=1",
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue