diff --git a/apps/web/e2e/deliberate-practice.spec.ts b/apps/web/e2e/deliberate-practice.spec.ts index a06816c..3787637 100644 --- a/apps/web/e2e/deliberate-practice.spec.ts +++ b/apps/web/e2e/deliberate-practice.spec.ts @@ -477,12 +477,18 @@ test.describe("G4 숙의 연습", () => { ); const episodeBody = submitted[0].episode as { episode_id: string; - attempts: Array<{ attempt_id: string }>; + attempts: Array<{ + attempt_id: string; + utterance_template_id?: string | null; + }>; }; expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/); expect(episodeBody.attempts[0].attempt_id).toMatch( /^oas-g4-attempt-[a-f0-9-]+$/, ); + expect(episodeBody.attempts[0].utterance_template_id).toBe( + "utterance-sha256:c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa", + ); await card.getByRole("button", { name: /05:04.*발화로 이동/ }).click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( diff --git a/apps/web/e2e/insecure-context-uuid.spec.ts b/apps/web/e2e/insecure-context-uuid.spec.ts index 0edb44b..ef82ae2 100644 --- a/apps/web/e2e/insecure-context-uuid.spec.ts +++ b/apps/web/e2e/insecure-context-uuid.spec.ts @@ -11,6 +11,24 @@ import { expect, test } from "@playwright/test"; * context. */ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const SHA256_VECTORS = [ + { + value: "", + digest: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + value: "abc", + digest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }, + { + value: "a".repeat(56), + digest: "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", + }, + { + value: "지금 이 이야기를 조금 더 다뤄도 괜찮을까요?", + digest: "c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa", + }, +] as const; test.describe("insecure-context idempotency keys", () => { test("randomUuid keeps working when crypto.randomUUID is unavailable", async ({ @@ -83,6 +101,78 @@ test.describe("insecure-context idempotency keys", () => { expect(new Set(values).size).toBe(2); }); + test("sha256Hex matches known vectors when crypto.subtle is unavailable", async ({ + page, + }) => { + await page.addInitScript(() => { + Object.defineProperty(globalThis.crypto, "subtle", { + value: undefined, + configurable: true, + }); + }); + await page.goto("/"); + + const probe = await page.evaluate(async (vectors) => { + const module = await import("/src/lib/sha256.ts"); + return { + subtlePresent: typeof globalThis.crypto.subtle, + digests: await Promise.all( + vectors.map(({ value }) => module.sha256Hex(value)), + ), + }; + }, SHA256_VECTORS); + + expect(probe.subtlePresent).toBe("undefined"); + expect(probe.digests).toEqual( + SHA256_VECTORS.map(({ digest }) => digest), + ); + }); + + test("sha256Hex falls back when a partial subtle implementation throws", async ({ + page, + }) => { + await page.addInitScript(() => { + let digestCalls = 0; + Object.defineProperty(globalThis, "__sha256DigestCalls", { + configurable: true, + get: () => digestCalls, + }); + Object.defineProperty(globalThis.crypto, "subtle", { + configurable: true, + value: { + digest: async () => { + digestCalls += 1; + throw new DOMException( + "partial Web Crypto implementation", + "NotSupportedError", + ); + }, + }, + }); + }); + await page.goto("/"); + + const probe = await page.evaluate(async (vectors) => { + const module = await import("/src/lib/sha256.ts"); + const digests = await Promise.all( + vectors.map(({ value }) => module.sha256Hex(value)), + ); + return { + subtlePresent: typeof globalThis.crypto.subtle, + digestCalls: ( + globalThis as typeof globalThis & { __sha256DigestCalls: number } + ).__sha256DigestCalls, + digests, + }; + }, SHA256_VECTORS); + + expect(probe.subtlePresent).toBe("object"); + expect(probe.digestCalls).toBe(SHA256_VECTORS.length); + expect(probe.digests).toEqual( + SHA256_VECTORS.map(({ digest }) => digest), + ); + }); + test("no product source calls crypto.randomUUID without the fallback", async () => { const { readFileSync, readdirSync, statSync } = await import("node:fs"); const path = await import("node:path"); @@ -105,4 +195,28 @@ test.describe("insecure-context idempotency keys", () => { walk(root); expect(offenders).toEqual([]); }); + + test("no product source calls subtle.digest outside the sha256 fallback", async () => { + const { readFileSync, readdirSync, statSync } = await import("node:fs"); + const path = await import("node:path"); + const root = path.join(process.cwd(), "src"); + const fallbackPath = path.join("lib", "sha256.ts"); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full); + continue; + } + if (!/\.(ts|tsx)$/.test(entry)) continue; + if (full.endsWith(fallbackPath)) continue; + if (/\bsubtle\s*\??\.\s*digest\s*\(/.test(readFileSync(full, "utf8"))) { + offenders.push(path.relative(root, full)); + } + } + }; + walk(root); + expect(offenders).toEqual([]); + }); }); diff --git a/apps/web/src/lib/sha256.ts b/apps/web/src/lib/sha256.ts new file mode 100644 index 0000000..f358c2c --- /dev/null +++ b/apps/web/src/lib/sha256.ts @@ -0,0 +1,116 @@ +const SHA256_INITIAL = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, + 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]); + +const SHA256_ROUND = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, + 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, + 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, + 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, + 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, + 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, + 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotateRight(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} + +function portableSha256(bytes: Uint8Array): string { + const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + + const view = new DataView(padded.buffer); + const bitLength = bytes.length * 8; + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000)); + view.setUint32(paddedLength - 4, bitLength >>> 0); + + const state = new Uint32Array(SHA256_INITIAL); + const words = new Uint32Array(64); + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let index = 0; index < 16; index += 1) { + words[index] = view.getUint32(offset + index * 4); + } + for (let index = 16; index < 64; index += 1) { + const previous = words[index - 15]; + const recent = words[index - 2]; + const sigma0 = + rotateRight(previous, 7) ^ + rotateRight(previous, 18) ^ + (previous >>> 3); + const sigma1 = + rotateRight(recent, 17) ^ + rotateRight(recent, 19) ^ + (recent >>> 10); + words[index] = + (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0; + } + + let a = state[0]; + let b = state[1]; + let c = state[2]; + let d = state[3]; + let e = state[4]; + let f = state[5]; + let g = state[6]; + let h = state[7]; + for (let index = 0; index < 64; index += 1) { + const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const choose = (e & f) ^ (~e & g); + const temporary1 = + (h + sum1 + choose + SHA256_ROUND[index] + words[index]) >>> 0; + const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temporary2 = (sum0 + majority) >>> 0; + h = g; + g = f; + f = e; + e = (d + temporary1) >>> 0; + d = c; + c = b; + b = a; + a = (temporary1 + temporary2) >>> 0; + } + + state[0] = (state[0] + a) >>> 0; + state[1] = (state[1] + b) >>> 0; + state[2] = (state[2] + c) >>> 0; + state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; + state[5] = (state[5] + f) >>> 0; + state[6] = (state[6] + g) >>> 0; + state[7] = (state[7] + h) >>> 0; + } + + return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join( + "", + ); +} + +export async function sha256Hex(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const subtle = globalThis.crypto?.subtle; + if (subtle) { + try { + return bytesToHex(new Uint8Array(await subtle.digest("SHA-256", bytes))); + } catch { + // Some embedded or insecure runtimes expose Web Crypto partially. + } + } + return portableSha256(bytes); +} diff --git a/apps/web/src/pages/session-review/DeliberatePracticeCard.tsx b/apps/web/src/pages/session-review/DeliberatePracticeCard.tsx index e39fca1..26195ee 100644 --- a/apps/web/src/pages/session-review/DeliberatePracticeCard.tsx +++ b/apps/web/src/pages/session-review/DeliberatePracticeCard.tsx @@ -20,6 +20,7 @@ import { } from "./deliberatePracticeApi"; import "./deliberate-practice.css"; import { randomUuid } from "../../lib/uuid"; +import { sha256Hex } from "../../lib/sha256"; interface PracticeTurn { id: string; @@ -195,14 +196,7 @@ function splitCounterevidence(value: string): string[] { async function phraseFingerprint(phrase: string): Promise { const normalized = phrase.normalize("NFKC").trim().replace(/\s+/g, " "); - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(normalized), - ); - const hex = Array.from(new Uint8Array(digest), (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - return `utterance-sha256:${hex}`; + return `utterance-sha256:${await sha256Hex(normalized)}`; } function turnForEvidence( diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a100466..eab2bd2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -380,6 +380,16 @@ apps/web/test-results/g7-voice-consent-mobile.png `/assets/index-*.js`를 차단하지 못한 환경 계약 누락이다. 두 entry를 함께 차단하도록 수정했고 Vite source 2/2와 production preview 2/2를 각각 통과했다. 전체 execute를 즉시 반복하지 않고 이 수정의 새 clean commit·archive를 다시 결속한 뒤 한 번만 재실행한다. +- 후속 clean commit `21461ab3…6fde`, tree `5c5f12a8…524e`, 2회 동일 archive `20d49694…fa1a` execute는 + candidate **112/112**를 통과하고 NAS 새 API/Web image `d5021950…`/`376a3aa8…`까지 올렸다. 그러나 실제 NAS-origin + postdeploy의 G4 deliberate-practice desktop/mobile 2건이 110/112에서 실패해 active-state commit 전에 이전 + `52e0e816…`/`6fdbb646…`로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route와 image ID를 재검증해 + rollback `verified`로 종료했다. +- focused exact-image 평문 origin에서 요청이 API까지 가지 않고 일반 오류로 끝나는 것을 재현했다. 원인은 + 문장 원문을 보내지 않기 위한 fingerprint가 `crypto.subtle.digest`에만 의존해 insecure origin에서 예외가 난 것이다. + 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest + `c06db0f0…62aa`를 payload에서 exact 검증했다. localhost secure origin 2/2와 실제 Tailnet insecure origin 2/2가 + 같은 digest로 통과했다. 이 수정의 새 clean commit만 다시 결속해 전체 execute를 재개한다. #### P0 수정 완료 — 회기 timestamp UTC 전송과 KST 표시 날짜 @@ -683,7 +693,7 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line ``` -전체 Playwright inventory는 614 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 +전체 Playwright inventory는 620 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다. ## 9. 핵심 변경 파일 diff --git a/docs/TODO.md b/docs/TODO.md index f968f08..de0d864 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -503,6 +503,9 @@ 닫기 우회는 없다. 첫 clean commit `5221f79e` execute는 API/types/build와 candidate DB-backed 회기 후반까지 통과했지만 Vite 전용 `/src/main.tsx` 차단 테스트가 production hashed entry를 차단하지 못해 110/2에서 mutation 없이 중단됐다. source와 production preview에서 수정 회귀 2/2씩 통과했다. 이 테스트 수정의 새 clean + commit `21461ab3` candidate 112/112 뒤 실제 NAS-origin G4 2건이 `crypto.subtle` 부재로 실패했고, release agent는 + 이전 image·health·auth·OpenAPI를 검증해 rollback했다. 문장 원문을 보내지 않는 portable SHA-256 fallback은 + secure localhost 2/2와 Tailnet insecure origin 2/2에서 동일 표준 digest를 통과했다. 이 수정의 새 clean commit·HEAD/tree/archive를 결속해 실제 `http://100.116.83.60:8088` origin에서 전체 회기 E2E 0 failure·SSE→DB review·student returned-practice desktop/mobile을 다시 검증하기 전에는 G8 전체를 DONE으로 diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index cf165cf..3ea4f24 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -656,6 +656,7 @@

188차 적용(2026-08-09): 회기 시간 P0를 우회 없이 수정했다. browser-facing started_at/ended_at과 learner/teacher dashboard timestamp는 UTC +00:00을 반환하고, 리뷰의 달력 날짜는 서버 locale 대신 KST(+09:00)를 사용한다. backend 관련 65 passed, 후속 voice 계약을 포함한 API 전체 921 passed, Ruff·compile·web typecheck를 통과했다. 고유 Compose DB/API/Web/engine과 Asia/Seoul browser에서 코칭 이력 focused 1/1은 active/null·UTC suffix·10분 미만 경과·timebar/dialog 0·reload 후 coach mark 실제 클릭과 source-pack dialog를 확인했다. 종료 뒤 container·volume·listener 0이다. 이제 새 clean commit 결속과 NAS-origin 전체 E2E가 남았다.

189차 적용(2026-08-09): G7 외부 실행 전 P0를 소스에서 닫았다. production runner exit 0은 canonical checker 0·gate_closed=true에 결속하고, browser Origin allowlist를 API/WSS/admin/topology transport host·scheme과 분리했다. capture는 최소 3,120초·ceil+terminal sample이며 checker는 voice/runtime/topology 공통 교집합 3,000초를 강제한다. Windows 증거는 detached-clean commit/tree, runner/collector/checker SHA, psutil==6.1.1을 매 sample 전후 검증한다. fresh public launcher는 mutation 전에 source·Python·cloudflared·config SHA를 고정하고, legacy API와 exact-config cloudflared를 bounded 교체해 새 PID/start/exe·command SHA/실제 cwd의 raw command-line 없는 receipt를 남긴다. receipt destination은 runtime mutation 전에 create/flush/atomic-replace 권한을 검사하고 기존 receipt를 보존한 채 원자 게시한다. 검증: G7 runner/checker/topology 86/86, launcher/sidecar 80/80, 통합 166/166, API 921, gateway 58, Web api-types·typecheck·build, 자기주도 desktop/mobile 6/6, Ruff·compile·PS5.1 parser·diff-check PASS. 실제 public 실행·마이크·사람 평가는 하지 않았으므로 G7은 external GATE다.

190차 적용(2026-08-09): exact include 52개를 clean commit 5221f79e…1c69으로 고정하고 tree e4f15001…308b, 2회 동일 archive 1109bf86…0f4b를 결속했다. fresh NAS dump 6f4b95a7…b529f(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 110/112에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry /src/main.tsx만 차단하던 boot 진단 테스트가 production hashed entry /assets/index-*.js를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.

+

191차 적용(2026-08-09): 후속 clean commit 21461ab3…6fde·tree 5c5f12a8…524e·archive 20d49694…fa1a는 candidate 112/112를 통과하고 NAS 새 API/Web d5021950…/376a3aa8…를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 52e0e816…/6fdbb646…로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback verified로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 crypto.subtle.digest 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -1066,7 +1067,7 @@ Web typechecknpm run typecheckPassed Design SSOT / auth visualnpm run check:design-ssot / npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line / npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=lineSSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed. - Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-09 현재 수집은 614 tests / 44 files다. 현 작업트리의 614개 전체 GREEN은 아직 검증 전이며, current 핵심 fixture는 불변 110 + 최신 dashboard 10의 분할 GREEN이다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. + Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-09 현재 수집은 620 tests / 44 files다. 현 작업트리의 620개 전체 GREEN은 아직 검증 전이며, current 핵심 fixture는 불변 110 + 최신 dashboard 10의 분할 GREEN이다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. Refactor governance P1~P8ruff check app / pytest -q app / pytest -q engine_gateway / npm run typecheck / npm run check:api-types / npm run check:design-ssot / npm run check:dead-code / npm run check:duplication / npm run build / npm audit --audit-level=high / full PlaywrightBackend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 ops/refactor-governance-2026-07-15.md. API typegen SSOTnpm run check:api-typesPassed; FastAPI OpenAPI → src/lib/api.gen.ts stale check Outcome & Alliance OS G0py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q / scripts/check-measurement-ledger.sql / measurement·API contract checks / web typecheck / DB-backed session-persistence focused E2E 3종G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료. diff --git a/docs/guides/testing.md b/docs/guides/testing.md index d2ac8f8..12f137c 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타 | API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | -| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 614 tests / 44 files · 현 작업트리 전체 GREEN 미검증 | +| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 620 tests / 44 files · 현 작업트리 전체 GREEN 미검증 | 핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.** **E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다. @@ -271,8 +271,8 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API ### 3.6 실측 테스트 개수 (현재) -2026-08-09 `npx playwright test --list` 기준 **현재 수집 614 tests / 44 files**다. -이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 604개 완주는 아직 증거가 없으며, +2026-08-09 `npx playwright test --list` 기준 **현재 수집 620 tests / 44 files**다. +이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 620개 완주는 아직 증거가 없으며, 과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다. - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83) diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 08e5bad..78e2335 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -39,6 +39,10 @@ 110/112에서 production hashed entry를 차단하지 못한 boot-diagnostic 테스트 desktop/mobile 2건으로 승격 전 중단됐다. NAS mutation 0이다. `/src/main.tsx`와 `/assets/index-*.js`를 함께 차단하는 수정은 Vite source 2/2, production preview 2/2를 통과했으며 새 clean commit으로 한 번만 재실행한다. + 후속 `21461ab3` candidate는 112/112를 통과했지만 실제 NAS-origin deliberate-practice 2건이 + `crypto.subtle.digest` 부재로 실패했고 이전 이미지 rollback·health/auth/OpenAPI 검증은 완료됐다. 원문을 서버로 + 보내지 않는 portable SHA-256 fallback과 exact digest assertion은 secure localhost 2/2·Tailnet insecure 2/2를 + 통과했다. 새 clean commit을 결속한 전체 execute 0 failure가 여전히 종료조건이다. > B1 완료 항목(학생 리뷰 1~5 척도 320px 반응형 재배치, 셸 구분선, 세션 종료 UX/다크테마, 아바타 SVG 리그 복귀와 래스터 비활성화, SEO/공유 카드, 레이아웃 정렬, 권한 위임, > 메일링, 아카이브 API, TTS voice map, 빈상태 레이아웃, 평가 실패 복구 UX, SSE 저장, live-coach 표면화,