diff --git a/.github/workflows/visual-accessibility-evidence.yml b/.github/workflows/visual-accessibility-evidence.yml new file mode 100644 index 00000000..dee9c225 --- /dev/null +++ b/.github/workflows/visual-accessibility-evidence.yml @@ -0,0 +1,54 @@ +name: Visual Accessibility Evidence + +on: + pull_request: + push: + branches: [develop] + +permissions: + contents: read + +concurrency: + group: visual-accessibility-evidence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + browser-evidence: + runs-on: ubuntu-latest + steps: + - name: Checkout exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + + - name: Setup Node 22.13 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + + - name: Install + run: npm ci + + - name: Install Playwright (chromium) + run: npx playwright install chromium --with-deps + + - name: Capture visual and accessibility evidence + run: npm run test:e2e -- tests/e2e/visual-accessibility-evidence.spec.js + + - name: Preserve visual accessibility evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scopeweave-visual-accessibility-${{ github.run_id }}-${{ github.run_attempt }} + path: test-results + if-no-files-found: error + retention-days: 3 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..f21c8dca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ - `toast-state.css`: cloud overlay `.toast.visible` rendering so SaaS status messages stay visually observable. - `app.js`: state, rendering, editing, validation, persistence, - import/export, and Gantt logic. + import/export, WBS filtering, and Gantt logic. - `analytics.js`: EVM, S-curve, CPM, workload, cost, and requirements/RFI/RFP WBS-estimation readiness analysis. - `wbs.json`: seed data in the user-specified JSON array format. @@ -35,6 +35,9 @@ optional File System Access API sync for `wbs.json` where supported. - Static hosting treats repository `wbs.json` as seed data; export/manual save remains the portability path. +- A first seed-only visit exposes an accessible onboarding notice; its dismissal + marker is separate from the planner payload, while confirmed clearing persists + an empty `tasks` array through the normal `renderAll()` and autosave path. - Imported flat JSON may synthesize hierarchy wrapper nodes internally, but external `wbs.json` sync strips synthetic rows so the saved array stays in the requested user schema. diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..52e26ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added accessible WBS field search with hierarchy context and an empty-result + recovery action. +- Added planning-field search coverage and safe state reset when Cloud or file + imports replace the current plan. +- Kept first-visit sample guidance visible when local persistence fails. +- Added browser JSON download for portable WBS backups, including extended + planning fields, alongside CSV export. +- Added first-visit sample WBS onboarding with persistent dismissal and a + confirmed clear-to-empty-plan action. +- Added search-mode guardrails that keep hierarchy edits and drag reordering + out of the filtered view while an inline editor is open. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON @@ -19,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +- Added exact-head Node/Chromium coverage collection and report merging; the + remaining uncovered paths still block a 100% frontend/backend claim. +- Made the canonical coverage run execute the complete unit and API suites so + existing pure-logic and server regression evidence is included in the merged report. +- Added browser regression coverage for empty-plan actions, unsupported file sync, + filtered hierarchy protection, collapse/expand, and oversized CSV rejection. +- Added browser coverage for cloud sharing, sprint burndown, attachments, + search, team administration, project creation, and sample onboarding flows. ### Security @@ -30,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced dynamic and lazy-regex MS Project XML block extraction with bounded linear scans to prevent pathological backtracking on malformed imports. - Rejected non-string password candidates at the authentication boundary. +- Fixed cloud login and team modal close controls when their nested icon is + clicked. - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. diff --git a/README.md b/README.md index 6340c1f4..61c7fbb7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ two modes: subtree reorder - Automatic day, weight, planned progress, actual progress, and weighted progress calculations -- CSV import/export using the screen column contract +- WBS search across task fields with matching hierarchy context +- First-visit sample WBS guidance with dismissible notice and clear-to-empty-plan action +- JSON/CSV export and CSV import using the screen column contract - Local autosave with optional File System Access API sync to `wbs.json` - Weekly Gantt modal with planned (`#333333`) and actual (`#34cb03`) overlays - Responsive column reduction for screens under 800px diff --git a/app.js b/app.js index a04aae71..8b8dcf07 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1'; +const ONBOARDING_DISMISSED_KEY = 'scopeweave:onboarding-dismissed:v1'; const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner'; const MAX_PROJECT_NAME_LENGTH = 120; const MAX_BASE_DATE_LENGTH = 10; @@ -178,6 +179,8 @@ const state = { projectName: DEFAULT_PROJECT_NAME, baseDate: formatLocalDateInput(new Date()), tasks: [], + taskQuery: '', + showSeedOnboarding: false, editor: { ...DEFAULT_EDITOR_STATE, errors: [] }, jsonSyncHandle: null, dragTaskId: null, @@ -214,8 +217,12 @@ const elements = { plannedProgress: document.getElementById('summary-planned-progress'), actualProgress: document.getElementById('summary-actual-progress'), tableBody: document.getElementById('task-table-body'), + seedOnboarding: document.getElementById('seed-onboarding'), + dismissSeedOnboardingButton: document.getElementById('dismiss-seed-onboarding'), + clearSeedDataButton: document.getElementById('clear-seed-data'), addRootButton: document.getElementById('add-root-task'), exportCsvButton: document.getElementById('export-csv'), + exportJsonButton: document.getElementById('export-json'), importCsvButton: document.getElementById('import-csv'), csvFileInput: document.getElementById('csv-file-input'), ganttModal: document.getElementById('gantt-modal'), @@ -224,6 +231,9 @@ const elements = { closeGanttButton: document.getElementById('close-gantt'), connectJsonSyncButton: document.getElementById('connect-json-sync'), syncStatus: document.getElementById('sync-status'), + taskFilterInput: document.getElementById('task-filter'), + clearTaskFilterButton: document.getElementById('clear-task-filter'), + taskFilterStatus: document.getElementById('task-filter-status'), toast: document.getElementById('toast') }; @@ -245,16 +255,19 @@ async function bootstrap() { const cloudState = cloudApi ? await cloudApi.boot() : null; if (cloudState) { + state.showSeedOnboarding = false; hydrateState(cloudState); persistState({ syncCloud: false }); } else { const savedState = loadLocalState(); if (savedState) { + state.showSeedOnboarding = false; hydrateState(savedState); persistState(); } else { const seedData = await loadSeedTasks(); state.tasks = normalizeImportedTasks(seedData); + state.showSeedOnboarding = state.tasks.length > 0 && !isSeedOnboardingDismissed(); invalidateTaskIndexCache(); } } @@ -302,7 +315,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { }); elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush); - elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() })); + elements.addRootButton.addEventListener('click', (event) => { + if (elements.addRootButton.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); elements.exportCsvButton.addEventListener('click', (e) => { if (elements.exportCsvButton.getAttribute('aria-disabled') === 'true') { e.preventDefault(); @@ -311,6 +331,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { } exportCsv(); }); + elements.exportJsonButton.addEventListener('click', (e) => { + if (elements.exportJsonButton.getAttribute('aria-disabled') === 'true') { + e.preventDefault(); + showToast('내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + return; + } + exportJson(); + }); elements.importCsvButton.addEventListener('click', () => elements.csvFileInput.click()); elements.csvFileInput.addEventListener('change', handleCsvImport); elements.openGanttButton.addEventListener('click', (e) => { @@ -336,6 +364,21 @@ function bindHeaderEvents(persistAndRenderMetadata) { } await connectJsonSync(); }); + + elements.taskFilterInput.addEventListener('input', (event) => { + if (state.editor.mode) { + return; + } + state.taskQuery = String(event.target.value).slice(0, 120); + renderAll(); + }); + elements.clearTaskFilterButton.addEventListener('click', () => { + state.taskQuery = ''; + renderAll(); + elements.taskFilterInput.focus(); + }); + elements.dismissSeedOnboardingButton.addEventListener('click', dismissSeedOnboarding); + elements.clearSeedDataButton.addEventListener('click', clearSeedData); } function bindModalEvents() { @@ -434,6 +477,10 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragstart', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row) { return; @@ -453,6 +500,9 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragover', (event) => { + if (state.taskQuery.trim()) { + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId || row.dataset.taskId === state.dragTaskId) { return; @@ -482,6 +532,11 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('drop', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + clearDragState(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId) { return; @@ -505,6 +560,8 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { const cachedHasChildrenSet = new Set(); function renderAll() { const metrics = computeTaskMetrics(); + const filterActive = Boolean(state.taskQuery.trim()); + const editorOpen = Boolean(state.editor.mode); elements.projectNameInput.value = state.projectName; document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`; @@ -513,6 +570,17 @@ function renderAll() { elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2); elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2); elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중'; + if (elements.taskFilterInput.value !== state.taskQuery) { + elements.taskFilterInput.value = state.taskQuery; + } + elements.taskFilterInput.disabled = editorOpen; + if (editorOpen) { + elements.taskFilterInput.setAttribute('aria-disabled', 'true'); + elements.taskFilterInput.title = '편집을 완료하거나 취소한 후 검색할 수 있습니다.'; + } else { + elements.taskFilterInput.removeAttribute('aria-disabled'); + elements.taskFilterInput.removeAttribute('title'); + } if (typeof window !== 'undefined') { window.ScopeWeaveAnalytics?.render?.({ @@ -528,17 +596,27 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; + elements.seedOnboarding.hidden = !state.showSeedOnboarding || filterActive; + elements.clearTaskFilterButton.hidden = !filterActive; + elements.taskFilterStatus.textContent = filterActive + ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` + : `전체 ${state.tasks.length}개 작업`; const hasTasks = state.tasks.length > 0; if (!hasTasks) { elements.exportCsvButton.setAttribute('aria-disabled', 'true'); + elements.exportJsonButton.setAttribute('aria-disabled', 'true'); elements.openGanttButton.setAttribute('aria-disabled', 'true'); } else { elements.exportCsvButton.removeAttribute('aria-disabled'); + elements.exportJsonButton.removeAttribute('aria-disabled'); elements.openGanttButton.removeAttribute('aria-disabled'); } elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; + elements.exportJsonButton.title = elements.exportCsvButton.title; elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; + elements.addRootButton.setAttribute('aria-disabled', String(filterActive)); + elements.addRootButton.title = filterActive ? '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.' : ''; // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) cachedHasChildrenSet.clear(); @@ -586,40 +664,47 @@ function createEmptyStateRow() { const icon = document.createElement('div'); icon.className = 'empty-icon'; icon.setAttribute('aria-hidden', 'true'); - icon.textContent = '📋'; + const filtered = Boolean(state.taskQuery.trim()); + icon.textContent = filtered ? '🔎' : '📋'; const title = document.createElement('h3'); title.className = 'empty-title'; - title.textContent = '등록된 작업이 없습니다'; + title.textContent = filtered ? '검색 결과가 없습니다' : '등록된 작업이 없습니다'; const description = document.createElement('p'); description.className = 'empty-desc'; - description.append( - "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", - document.createElement('br'), - "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." - ); + if (filtered) { + description.textContent = `‘${state.taskQuery}’에 일치하는 작업이 없습니다.`; + } else { + description.append( + "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", + document.createElement('br'), + "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." + ); + } const actions = document.createElement('div'); actions.className = 'empty-actions editor-actions'; - const addRootBtn = document.createElement('button'); - addRootBtn.type = 'button'; - addRootBtn.className = 'primary-button'; - addRootBtn.textContent = '최상위 작업 추가'; - addRootBtn.addEventListener('click', () => { - openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); - }); + if (!filtered) { + const addRootBtn = document.createElement('button'); + addRootBtn.type = 'button'; + addRootBtn.className = 'primary-button'; + addRootBtn.textContent = '최상위 작업 추가'; + addRootBtn.addEventListener('click', () => { + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); - const importCsvBtn = document.createElement('button'); - importCsvBtn.type = 'button'; - importCsvBtn.className = 'secondary-button'; - importCsvBtn.textContent = 'CSV 가져오기'; - importCsvBtn.addEventListener('click', () => { - document.getElementById('csv-file-input').click(); - }); + const importCsvBtn = document.createElement('button'); + importCsvBtn.type = 'button'; + importCsvBtn.className = 'secondary-button'; + importCsvBtn.textContent = 'CSV 가져오기'; + importCsvBtn.addEventListener('click', () => { + document.getElementById('csv-file-input').click(); + }); - actions.append(addRootBtn, importCsvBtn); + actions.append(addRootBtn, importCsvBtn); + } emptyState.append(icon, title, description, actions); cell.appendChild(emptyState); @@ -673,6 +758,8 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const row = taskRowTemplate.cloneNode(false); row.className = `task-row depth-${task.depth} ${index % 2 === 1 ? 'striped-even' : ''}`; row.dataset.taskId = task.id; + const filterActive = Boolean(state.taskQuery.trim()); + row.draggable = !filterActive; const actionCell = actionCellTemplate.cloneNode(false); const actionStack = actionStackTemplate.cloneNode(false); @@ -681,12 +768,21 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { if (hasChildren) { const toggleButton = toggleButtonTemplate.cloneNode(false); - const toggleLabel = task.expanded ? '접기' : '펼치기'; + const searchExpanded = cachedSearchExpandedParentIds.has(task.id); + const expanded = searchExpanded || task.expanded; + const toggleLabel = searchExpanded + ? '검색 중 계층 맥락 고정' + : (filterActive ? '검색 중 비활성화' : (task.expanded ? '접기' : '펼치기')); toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); - toggleButton.setAttribute('aria-expanded', String(task.expanded)); + toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; + if (filterActive || searchExpanded) { + toggleButton.setAttribute('aria-disabled', 'true'); + } else { + toggleButton.removeAttribute('aria-disabled'); + } const toggleIcon = toggleIconTemplate.cloneNode(false); - toggleIcon.textContent = task.expanded ? '▼' : '▶'; + toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); actionStack.appendChild(toggleButton); } else { @@ -699,8 +795,11 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const isLeaf = task.depth >= 3; const addChildButton = createActionButton(`하위 추가 - ${rowEntityName}`, '+', 'add-child', isLeaf ? '최대 3단계까지만 추가할 수 있습니다.' : `하위 추가 - ${rowEntityName}`); - if (isLeaf) { + if (isLeaf || filterActive) { addChildButton.setAttribute('aria-disabled', 'true'); + if (filterActive && !isLeaf) { + addChildButton.title = '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'; + } } else { addChildButton.removeAttribute('aria-disabled'); } @@ -708,7 +807,12 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const editButton = createActionButton(`편집 - ${rowEntityName}`, '✎', 'edit', `편집 - ${rowEntityName}`); editButton.setAttribute('aria-haspopup', 'dialog'); - const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', `삭제 - ${rowEntityName}`); + const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', filterActive + ? '검색 중에는 작업을 삭제할 수 없습니다. 검색을 먼저 지워주세요.' + : `삭제 - ${rowEntityName}`); + if (filterActive) { + deleteButton.setAttribute('aria-disabled', 'true'); + } actionStack.append( dragHandle, @@ -1113,6 +1217,11 @@ function handleRowAction(action, taskId) { return; } + if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child' || action === 'delete')) { + showToast('검색 중에는 계층을 변경할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + if (action === 'toggle') { task.expanded = !task.expanded; persistState(); @@ -1176,6 +1285,10 @@ function handleRowAction(action, taskId) { } function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertAfterId = null, draft = null }) { + if (mode === 'create' && state.taskQuery.trim()) { + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } state.previousFocus = document.activeElement; if (mode === 'edit') { const task = findTask(targetId); @@ -1496,10 +1609,43 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); +const cachedSearchExpandedParentIds = new Set(); +const TASK_SEARCH_FIELDS = [ + 'name', 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', + 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', + 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'budget', + 'actualCost', 'sprint', 'storyPoints' +]; + +function taskSearchText(task) { + return TASK_SEARCH_FIELDS.map((field) => String(task[field] ?? '')).join(' ').toLowerCase(); +} function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); + cachedSearchExpandedParentIds.clear(); + + const query = state.taskQuery.trim().toLowerCase(); + if (query) { + const tasksById = new Map(state.tasks.map((task) => [task.id, task])); + const matchingIds = new Set(); + state.tasks.forEach((task) => { + if (taskSearchText(task).includes(query)) { + let current = task; + const visitedIds = new Set(); + while (current && !visitedIds.has(current.id)) { + visitedIds.add(current.id); + matchingIds.add(current.id); + if (current.id !== task.id) { + cachedSearchExpandedParentIds.add(current.id); + } + current = tasksById.get(current.parentId); + } + } + }); + return state.tasks.filter((task) => matchingIds.has(task.id)); + } // ⚡ Bolt Optimization: Single-pass O(N) visible task filtering to avoid redundant O(N * Depth) tree traversals state.tasks.forEach((task) => { @@ -1645,9 +1791,11 @@ function persistState({ syncCloud = true } = {}) { }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + state.showSeedOnboarding = false; } catch (error) { console.error('State persistence failed:', error); showToast('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); + return false; } if (state.jsonSyncHandle) { @@ -1659,6 +1807,46 @@ function persistState({ syncCloud = true } = {}) { if (syncCloud && typeof window !== 'undefined') { window.ScopeWeaveCloud?.push?.(payload); } + return true; +} + +function isSeedOnboardingDismissed() { + try { + return localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true'; + } catch { + return false; + } +} + +function dismissSeedOnboarding() { + try { + localStorage.setItem(ONBOARDING_DISMISSED_KEY, 'true'); + } catch { + // The notice is still dismissible for the current session if storage is unavailable. + } + state.showSeedOnboarding = false; + renderAll(); +} + +function clearSeedData() { + if (!state.showSeedOnboarding || !window.confirm('샘플 데이터를 지우고 빈 계획으로 시작하시겠습니까?')) { + return; + } + const previousTasks = state.tasks; + const previousOnboarding = state.showSeedOnboarding; + state.tasks = []; + state.showSeedOnboarding = false; + invalidateTaskIndexCache(); + if (!persistState()) { + state.tasks = previousTasks; + state.showSeedOnboarding = previousOnboarding; + invalidateTaskIndexCache(); + renderAll(); + return; + } + renderAll(); + showToast('샘플 데이터를 삭제했습니다. 첫 단계를 추가해 계획을 시작하세요.'); + requestAnimationFrame(() => elements.addRootButton.focus()); } function loadLocalState() { @@ -1676,6 +1864,8 @@ function hydrateState(savedState) { state.tasks = Array.isArray(savedState.tasks) ? savedState.tasks.filter(isTaskRecord).map(normalizeStoredTask) : []; + state.taskQuery = ''; + state.showSeedOnboarding = false; invalidateTaskIndexCache(); } @@ -1975,6 +2165,11 @@ function exportCsv() { downloadFile(csvText, `wbs_export_${formatCompactDate(new Date())}.csv`, 'text/csv;charset=utf-8'); } +function exportJson() { + const jsonText = JSON.stringify(exportJsonArray({ includeExtendedFields: true }), null, 2); + downloadFile(jsonText, `wbs_export_${formatCompactDate(new Date())}.json`, 'application/json;charset=utf-8'); +} + async function handleCsvImport(event) { const [file] = event.target.files || []; if (!file) { @@ -1997,8 +2192,8 @@ async function handleCsvImport(event) { try { const text = await file.text(); const imported = parseCsv(text); - state.tasks = validateImportedTasks(normalizeImportedTasks(imported)); - invalidateTaskIndexCache(); + const importedTasks = validateImportedTasks(normalizeImportedTasks(imported)); + hydrateState({ projectName: state.projectName, baseDate: state.baseDate, tasks: importedTasks }); closeEditor(true); persistState(); renderAll(); @@ -2191,23 +2386,38 @@ async function writeJsonSyncFile() { await writable.close(); } -function exportJsonArray() { - return state.tasks.filter((task) => !task.isSynthetic).map((task) => ({ - phase: task.phase, - activity: task.activity, - task: task.task, - categoryLarge: task.categoryLarge, - categoryMedium: task.categoryMedium, - documentName: task.documentName, - owner: task.owner, - supportTeam: task.supportTeam, - plannedStartDate: task.plannedStartDate, - plannedEndDate: task.plannedEndDate, - [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, - actualProgressStatus: task.actualProgressStatus, - actualStartDate: task.actualStartDate, - actualEndDate: task.actualEndDate - })); +function exportJsonArray({ includeExtendedFields = false } = {}) { + return state.tasks.filter((task) => !task.isSynthetic).map((task) => { + const record = { + phase: task.phase, + activity: task.activity, + task: task.task, + categoryLarge: task.categoryLarge, + categoryMedium: task.categoryMedium, + documentName: task.documentName, + owner: task.owner, + supportTeam: task.supportTeam, + plannedStartDate: task.plannedStartDate, + plannedEndDate: task.plannedEndDate, + [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, + actualProgressStatus: task.actualProgressStatus, + actualStartDate: task.actualStartDate, + actualEndDate: task.actualEndDate + }; + if (includeExtendedFields) { + Object.assign(record, { + name: task.name, + plannedProgress: task.plannedProgress, + actualProgress: task.actualProgress, + predecessors: task.predecessors ?? '', + budget: task.budget ?? '', + actualCost: task.actualCost ?? '', + sprint: task.sprint ?? '', + storyPoints: task.storyPoints ?? '' + }); + } + return record; + }); } function openGanttModal() { diff --git a/cloud-sync.js b/cloud-sync.js index 0e015ebe..be0924e6 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -203,7 +203,7 @@ function ensureAuthUI() { }; $('#cloud-toggle').addEventListener('click', () => setMode(mode === 'login' ? 'signup' : 'login')); $('#cloud-sso').addEventListener('click', () => { window.location.href = '/api/auth/oidc/start'; }); - modal.addEventListener('click', (e) => { if (e.target.dataset.cloudClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-cloud-close]')) modal.classList.add('hidden'); }); $('#cloud-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = $('#cloud-email').value.trim(); @@ -1748,7 +1748,7 @@ async function openTeamModal() {

`; document.body.appendChild(modal); - modal.addEventListener('click', (e) => { if (e.target.dataset.teamClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-team-close]')) modal.classList.add('hidden'); }); modal.querySelector('#team-invite').addEventListener('submit', async (e) => { e.preventDefault(); const email = modal.querySelector('#team-email').value.trim(); diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md new file mode 100644 index 00000000..ed398a73 --- /dev/null +++ b/docs/doctoring/coverage-evidence.md @@ -0,0 +1,53 @@ +# Coverage evidence and 100% readiness + +## Exact-head measurement + +On 2026-08-29, PR #632 source/test working head +`bce21b4eb53bf22ea454fd8bce0450b4f49bced5` +ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with +a dedicated ScopeWeave static server. The merged Node/Chromium summary +contained 291 source entries: + +| Metric | Covered | Total | Result | +| --- | ---: | ---: | ---: | +| Lines/statements | 8,252 | 8,688 | 94.98% | +| Functions | 291 | 298 | 97.65% | +| Branches | 2,370 | 2,541 | 93.27% | + +The command's successful exit means the listed test cases completed and the +Node plus browser reports were merged; it does not mean the 100% quality +target was met. `node scripts/ci/check-coverage.mjs` fails with all four +thresholded metrics below 100%. + +## Scope boundary + +The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, +`analytics.js`, the CI helper, and every `server/*.mjs` module while executing +the complete unit and API suites. The browser +phase collects Chromium V8 JavaScript coverage during all 97 passing E2E +tests, converts the shipped client scripts, and merges both reports into one +Istanbul summary. Uncovered production lines remain in the report. + +## Required remediation + +1. Add tests for every remaining server branch and client error/empty-state + path, including the current `app.js` and `cloud-sync.js` uncovered regions. +2. Keep the merged exact-head report and make the strict command pass 100% + lines, statements, functions, and branches. + +Until those conditions are true, G-06 remains **측정됨, 진행 중** and no +release note may describe the repository as having 100% coverage. + +## References + +bcoe. (n.d.). *c8: Output coverage reports using Node.js' built-in coverage* +[Computer software]. GitHub. Retrieved August 29, 2026, from +https://github.com/bcoe/c8 + +Microsoft. (n.d.). *Coverage*. Playwright. Retrieved August 29, 2026, from +https://playwright.dev/docs/api/class-coverage + +## Rollback + +Remove this record, the G-06 baseline row, and its changelog entry together; +there is no runtime or persisted-data impact. diff --git a/docs/doctoring/visual-accessibility-evidence.md b/docs/doctoring/visual-accessibility-evidence.md new file mode 100644 index 00000000..7e7eed7f --- /dev/null +++ b/docs/doctoring/visual-accessibility-evidence.md @@ -0,0 +1,19 @@ +# Visual and accessibility evidence + +## Status + +The repository-local `Visual Accessibility Evidence` workflow captures real Chromium screenshots for the sample, skip-link focus, and empty-plan states on the exact pull-request head. It retains the artifact for three days as release evidence. + +## WCAG 2.2 baseline checks + +The browser test also verifies the skip link target, keyboard-focusable `main` landmark, labeled WBS search control, scoped table headers, and body foreground/background contrast at the WCAG 2.2 normal-text threshold of 4.5:1. These checks are intentionally limited to deterministic contracts; they do not claim a complete automated accessibility audit. + +No Storybook, Figma runtime, axe dependency, or application runtime dependency is needed for this static-hostable product. Design artifacts remain the rendered production page and its retained browser evidence. + +## Exact-head and artifact contract + +The workflow checks out `github.event.pull_request.head.sha`, verifies `git rev-parse HEAD`, uses no credential persistence, and uploads only the Playwright `test-results` evidence with a three-day retention limit. A failed browser test still retains any screenshots produced before the failure. + +## Rollback + +Rollback removes the workflow and its test. There is no persisted-data, API, authentication, or schema impact. diff --git a/docs/plans/2026-04-20-scopeweave-design.md b/docs/plans/2026-04-20-scopeweave-design.md index 62c4824d..ea58fec4 100644 --- a/docs/plans/2026-04-20-scopeweave-design.md +++ b/docs/plans/2026-04-20-scopeweave-design.md @@ -47,6 +47,10 @@ ## Persistence decision - `wbs.json` in the repository is treated as the initial seed and export format. - Every data mutation autosaves immediately to `localStorage`. +- On a first visit with only seed data, the UI labels the sample explicitly and + provides a dismissible notice or a confirmed clear-to-empty-plan action. +- The onboarding dismissal marker is stored separately from the planner payload + so the existing seed/autosave contract remains unchanged. - On browsers supporting File System Access API, the user can grant a writable handle for `wbs.json`; after that, each change also writes the JSON array to the chosen file automatically. - Where that API is unavailable, the app remains functional and exposes explicit JSON/CSV export paths; this is the safest achievable static-hosting behavior. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..60cdf744 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,131 @@ +# ScopeWeave 제품·기술 Gap Baseline + +> 기준일: 2026-08-29 | 기준 브랜치: `develop` | 기준 HEAD: `2c328875e00e86537df3e965170be80532571cad` + +이 문서는 현재 저장소의 PRD, 기술 계약, 구현, 테스트, 운영 게이트를 한 +곳에서 추적하는 기준선이다. 문서의 상태는 의도나 열린 PR의 제목이 아니라 +현재 파일과 실행 증거를 기준으로 기록한다. + +## 1. 제품 목표와 구매자 + +주 구매자는 일정·공정 데이터를 WBS로 관리하고, 계획 대비 실적과 지연 +원인을 설명해야 하는 PM과 PMO다. 제품의 핵심 가치는 다음 세 가지다. + +1. `단계 > Activity > Task` 구조를 빠르게 편집한다. +2. 날짜·진척·선행작업에서 일정 통제 신호를 재현 가능하게 계산한다. +3. CSV와 브라우저 저장을 통해 별도 플랫폼에서도 계획을 회수한다. + +현재 범위는 정적 브라우저 클라이언트와 선택적 Node SaaS 계층이다. 정적 +호스팅에서 서버 파일을 덮어쓰지 않는다는 제약은 유지한다. + +## 2. PRD/TRD 추적성 + +| 요구 | 현재 구현 증거 | 상태 | +| --- | --- | --- | +| 3단계 WBS 편집·계층 보존 | `app.js`의 단일 `state.tasks`, `renderAll()`, expand/collapse·subtree 이동 | 완료 | +| 계획/실적 진척 및 일정 통제 | `analytics.js`의 EVM, S-curve, CPM, workload, PM readiness | 완료 | +| 계획을 찾고 계층 맥락을 유지 | `#task-filter`, `getVisibleTasks()`, `tests/e2e/scopeweave.spec.js` 검색 회귀 | 완료(이번 변경) | +| CSV 왕복 | `exportCsv()`, CSV parser/validation, E2E·fuzz 테스트 | 완료 | +| JSON seed·로컬 자동 저장 | `loadSeedTasks()`, `localStorage`, `exportJsonArray()`, JSON download | 완료 | +| 정적 배포 | `pages.yml`, 상대 경로 자산, `404.html` | 구현 완료, 실제 출판은 별도 런타임 증거 필요 | +| Cloud 인증·멀티테넌시·협업 | `server/`, `cloud-sync.js`, API smoke/E2E | 코드·테스트 존재, 운영 배포는 환경별 검증 필요 | + +## 3. UML 및 데이터 흐름 + +```mermaid +classDiagram + class ScopeWeaveState { + +string projectName + +string baseDate + +Task[] tasks + +string taskQuery + } + class Task { + +string id + +string parentId + +number depth + +string phase + +string activity + +string task + +string plannedStartDate + +string plannedEndDate + } + class AppController { + +bootstrap() + +renderAll() + +persistState() + } + class AnalyticsBridge { + +render(input) + +computeCpm(tasks) + +computeEvm(input) + } + class BrowserStorage { + +load() + +save(state) + } + ScopeWeaveState "1" *-- "0..*" Task + AppController --> ScopeWeaveState + AppController --> AnalyticsBridge + AppController --> BrowserStorage +``` + +`tasks`가 유일한 원천이며, 사용자 입력·파일 seed·Cloud snapshot은 이 +상태로 정규화된다. 화면 갱신은 `renderAll()` 하나를 통과하고, 분석은 +`window.ScopeWeaveAnalytics` 경계를 통해 선택적으로 호출된다. + +## 4. Gap 및 조치 상태 + +| ID | Gap / 고객 영향 | 조치 | 상태 | +| --- | --- | --- | --- | +| G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물·예산·실투입비·스토리포인트 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | +| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | +| G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | +| G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | +| G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | +| G-06 | 현재 합산 coverage가 저장소 전체 100%에 미달해 모든 클라이언트·서버 경로를 증명하지 못함 | Node c8과 Chromium V8 결과를 같은 exact-head 보고서로 합산하고 남은 경로를 보강한 뒤 100% threshold 통과 시에만 완료 처리 | **측정됨, 진행 중** | + +## 5. 품질·보안 기준선 + +- 런타임 의존성은 브라우저 native API와 현재 서버의 최소 의존성만 사용한다. +- `app.js`는 `new Function` 테스트 계약 때문에 top-level ESM import/export를 + 사용하지 않는다. +- 입력은 신뢰 경계에서 길이·날짜·CSV 수식·JSON prototype pollution을 + 검증하고, 동적 HTML 삽입 대신 `textContent`를 사용한다. +- 접근성은 레이블, landmark, keyboard focus, live status, disabled 상태, + reduced motion을 최소 기준으로 삼는다. +- 검증 명령은 `npm run test:unit`, `npm run test:api`, + `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. + `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 + source/test working head `bce21b4eb53bf22ea454fd8bce0450b4f49bced5` 측정치는 전용 + ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements + 94.98%, functions 97.65%, branches 93.27%이며, + `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. + 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 + 기준의 미충족 증거로 취급한다. + G-01/G-02/G-03의 직접 증거는 검색·JSON·온보딩 회귀 테스트이며, G-04는 + `tests/e2e/visual-accessibility-evidence.spec.js`와 `Visual Accessibility Evidence` + artifact가 exact-head 브라우저 상태를 증명한다. + +## 6. 표준·연구 근거 + +- 국제표준화기구. (2020). *ISO 21502:2020: Project, programme and portfolio + management—Guidance on project management*. https://www.iso.org/standard/74947.html +- 국제표준화기구. (2018). *ISO 21511:2018: Work breakdown structures for + project and programme management*. https://www.iso.org/standard/69702.html + 현재 개정안 ISO/DIS 21511은 초안이므로 이 기준선의 normative 계약으로 + 사용하지 않는다. +- 국제표준화기구. (2026). *ISO 21508:2026: Project, programme and portfolio + management—Earned value management*. https://www.iso.org/standard/87899.html +- World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines + (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ +- Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software + development framework (SSDF) version 1.1: Recommendations for mitigating + the risk of software vulnerabilities* (NIST Special Publication 800-218). + National Institute of Standards and Technology. + https://doi.org/10.6028/NIST.SP.800-218 + +Repository-specific research and existing design decisions remain linked from +[`docs/plans/2026-04-20-scopeweave-design.md`](plans/2026-04-20-scopeweave-design.md), +[`docs/research/pm-analysis/README.md`](research/pm-analysis/README.md), and +[`ARCHITECTURE.md`](../ARCHITECTURE.md). diff --git a/docs/user-guide.md b/docs/user-guide.md index fb32af2f..ab5795ec 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -10,7 +10,39 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 2. 하단 **최상위 작업 추가** 또는 각 행의 **+** 버튼으로 작업을 추가합니다. 3. 행 클릭 또는 **✎** 버튼으로 인라인 편집 모드를 엽니다. 4. 계획/실적 날짜와 실적진척상태를 입력하면 요약 수치와 가중치가 자동 재계산됩니다. -5. **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 산출물을 활용합니다. +5. **JSON 내보내기** / **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 + 산출물을 활용합니다. + +- **JSON 내보내기**는 선행작업, 예산, 실투입비, 스프린트, 스토리포인트를 + 포함한 휴대용 백업 파일(`wbs_export_YYYYMMDD.json`)을 다운로드합니다. + +## 첫 방문 샘플 + +- 저장된 계획이 없는 첫 방문에는 `wbs.json`의 예시 데이터를 구분할 수 있도록 + **샘플 WBS가 준비되어 있습니다** 안내가 표시됩니다. +- 예시를 둘러보려면 **안내 숨기기**를 선택합니다. 이 선택은 같은 브라우저에서 + 유지되며 계획 데이터와 별도로 저장됩니다. +- 실제 계획으로 시작하려면 **샘플 지우고 시작**을 선택하고 확인합니다. 샘플은 + 삭제되어 빈 계획으로 저장되고, 첫 단계를 추가할 수 있도록 최상위 작업 추가 + 버튼에 포커스가 이동합니다. +- 이미 로컬 또는 Cloud 계획이 저장된 브라우저에는 seed 안내가 다시 표시되지 + 않습니다. +- 프로젝트 이름이나 기준일을 먼저 바꾸어 저장하면 현재 seed가 사용자의 로컬 + 계획으로 채택된 것으로 간주되어 안내가 종료됩니다. 샘플을 둘러본 뒤 실제 + 계획을 시작할 때는 **샘플 지우고 시작**을 먼저 선택하세요. +- `wbs.json 자동저장 연결`은 샘플을 연결된 파일에 저장할 뿐이므로 안내를 + 종료하지 않습니다. 샘플을 계속 사용할지, 숨기거나 지울지 명시적으로 + 선택할 수 있습니다. + +## WBS 검색 + +- WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 + 필드를 검색합니다. +- 일치한 작업의 상위 계층은 함께 표시되어 검색 결과의 맥락을 유지합니다. +- 검색 중에는 계층 맥락을 유지하기 위해 상위 행의 접기 버튼이 비활성화됩니다. +- 검색 중에는 계층을 바꿀 수 없도록 드래그와 최상위/하위 작업 추가도 비활성화됩니다. +- 검색어는 브라우저 저장 데이터에 포함되지 않으며, **검색 지우기**로 전체 + 계층을 즉시 복원할 수 있습니다. ## 계층 규칙 @@ -32,6 +64,7 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - Chromium 계열 브라우저에서 **wbs.json 자동저장 연결** 버튼을 누르면 쓰기 가능한 `wbs.json` 파일을 연결할 수 있습니다. - 연결이 끝나면 변경할 때마다 같은 JSON 스키마로 자동 저장됩니다. - 외부 저장 JSON에는 내부 계층 관리용 synthetic row / internal id 필드가 포함되지 않습니다. +- 자동저장 연결은 seed 호환 스키마를 유지하며, 브라우저 다운로드는 추가 계획 필드까지 보존합니다. ## CSV 사용 diff --git a/index.html b/index.html index d24b2a88..73fe3281 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + @@ -53,6 +55,25 @@

ScopeWeave Planner

+
+ + + 전체 0개 작업 +
+
@@ -89,6 +110,7 @@

ScopeWeave Planner

+
diff --git a/package-lock.json b/package-lock.json index 00a99254..ff493704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" }, "engines": { "node": "^22.13.0 || >=23.4.0" diff --git a/package.json b/package.json index 8cefdc74..1474e7b4 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/cloud-sync-metrics.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", + "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=\"server/*.mjs\" --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", + "test:coverage:cases": "npm run test:unit && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", @@ -29,6 +31,8 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" } } diff --git a/scripts/ci/check-coverage.mjs b/scripts/ci/check-coverage.mjs new file mode 100644 index 00000000..7eca2a75 --- /dev/null +++ b/scripts/ci/check-coverage.mjs @@ -0,0 +1,10 @@ +import { readFile } from 'node:fs/promises'; + +const summary = JSON.parse(await readFile('coverage/coverage-summary.json', 'utf8')); +const failed = ['lines', 'statements', 'functions', 'branches'].filter((metric) => Number(summary[metric].pct) < 100); + +if (failed.length) { + throw new Error(`Coverage below 100%: ${failed.join(', ')}`); +} + +console.log('Coverage meets the 100% lines, statements, functions, and branches threshold.'); diff --git a/scripts/ci/merge-browser-coverage.mjs b/scripts/ci/merge-browser-coverage.mjs new file mode 100644 index 00000000..e5ecf146 --- /dev/null +++ b/scripts/ci/merge-browser-coverage.mjs @@ -0,0 +1,44 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import v8ToIstanbul from 'v8-to-istanbul'; +import coverageLib from 'istanbul-lib-coverage'; + +const { createCoverageMap } = coverageLib; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const nodeReportPath = path.join(root, 'coverage/node/coverage-final.json'); +const browserDirectory = path.join(root, 'coverage/browser'); +const outputDirectory = path.join(root, 'coverage'); +const browserFiles = (await readdir(browserDirectory)).filter((file) => file.endsWith('.json')); +const coverageMap = createCoverageMap(JSON.parse(await readFile(nodeReportPath, 'utf8'))); +const browserSources = new Map([ + ['/app.js', path.join(root, 'app.js')], + ['/cloud-sync.js', path.join(root, 'cloud-sync.js')], + ['/analytics.js', path.join(root, 'analytics.js')], +]); +let mergedEntries = 0; + +for (const file of browserFiles) { + const entries = JSON.parse(await readFile(path.join(browserDirectory, file), 'utf8')); + for (const entry of entries) { + const pathname = new URL(entry.url).pathname; + const localPath = browserSources.get(pathname); + if (!localPath || !entry.source) continue; + + const converter = v8ToIstanbul(localPath, 0, { source: entry.source }); + await converter.load(); + converter.applyCoverage(entry.functions); + coverageMap.merge(createCoverageMap(converter.toIstanbul())); + mergedEntries += 1; + } +} + +if (mergedEntries === 0) { + throw new Error('No browser coverage entries were merged'); +} + +const summary = coverageMap.getCoverageSummary().toJSON(); +await writeFile(path.join(outputDirectory, 'coverage-final.json'), JSON.stringify(coverageMap.toJSON(), null, 2)); +await writeFile(path.join(outputDirectory, 'coverage-summary.json'), JSON.stringify(summary, null, 2)); +console.log(JSON.stringify({ mergedEntries, summary }, null, 2)); diff --git a/scripts/ci/run-browser-coverage.mjs b/scripts/ci/run-browser-coverage.mjs new file mode 100644 index 00000000..8257a9b3 --- /dev/null +++ b/scripts/ci/run-browser-coverage.mjs @@ -0,0 +1,24 @@ +import { spawn } from 'node:child_process'; +import { mkdir, rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const coverageDirectory = path.join(root, 'coverage/browser'); + +await rm(coverageDirectory, { recursive: true, force: true }); +await mkdir(coverageDirectory, { recursive: true }); + +const exitCode = await new Promise((resolve, reject) => { + const child = spawn('npm', ['run', 'test:e2e'], { + cwd: root, + env: { ...process.env, SCOPEWEAVE_BROWSER_COVERAGE: '1' }, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); +}); + +if (exitCode !== 0) { + process.exitCode = exitCode; +} diff --git a/styles.css b/styles.css index 9d715f00..7ad10995 100644 --- a/styles.css +++ b/styles.css @@ -34,6 +34,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + body { margin: 0; background: var(--bg); @@ -205,6 +209,93 @@ button { min-width: 0; } +.table-toolbar { + display: flex; + align-items: end; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface-header); +} + +.task-filter { + display: flex; + flex: 1 1 360px; + max-width: 560px; + flex-direction: column; + gap: 6px; + color: var(--text-muted); + font-size: 0.8125rem; + font-weight: 700; +} + +.task-filter input { + min-height: 44px; + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: #ffffff; + color: var(--text); + padding: 10px 14px; +} + +.task-filter-status { + min-height: 44px; + display: inline-flex; + align-items: center; + color: var(--text-muted); + font-size: 0.875rem; + white-space: nowrap; +} + +.seed-onboarding { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + margin: 0 20px 18px; + padding: 18px 20px; + border: 1px solid #99f6e4; + border-left: 4px solid #0f766e; + border-radius: var(--radius-sm); + background: linear-gradient(120deg, #f0fdfa 0%, #ffffff 72%); + box-shadow: var(--shadow-sm); +} + +.seed-onboarding-mark { + color: #0f766e; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.seed-onboarding-copy h2 { + margin: 0 0 4px; + color: var(--text); + font-size: 1rem; + line-height: 1.35; +} + +.seed-onboarding-copy p { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.55; +} + +.seed-onboarding-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.filter-clear { + min-height: 44px; +} + .table-scroll { overflow: auto; contain: paint; @@ -841,6 +932,36 @@ select[data-inline-progress]:focus { min-width: 1300px; } + .table-toolbar { + align-items: stretch; + flex-wrap: wrap; + padding: 16px; + } + + .task-filter { + flex-basis: 100%; + max-width: none; + } + + .task-filter-status { + min-height: 0; + } + + .seed-onboarding { + grid-template-columns: 1fr; + gap: 10px; + margin: 0 16px 16px; + padding: 16px; + } + + .seed-onboarding-actions { + justify-content: stretch; + } + + .seed-onboarding-actions > button { + flex: 1 1 180px; + } + .priority-desktop { display: none; } diff --git a/tests/e2e/beforeunload.spec.js b/tests/e2e/beforeunload.spec.js index c94c44ae..8a8d8366 100644 --- a/tests/e2e/beforeunload.spec.js +++ b/tests/e2e/beforeunload.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test.describe('Inline editor unsaved-change guards', () => { test('Escape on dirty editor prompts before discard', async ({ page }) => { diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index fa18cc2e..942de019 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -1,8 +1,9 @@ // Cloud (SaaS) UI e2e — self-contained: spawns the Node API server itself, so // the static python webServer from playwright.config is untouched. // Run: npx playwright test tests/e2e/cloud.spec.js -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; const PORT = 8830; const BASE = `http://127.0.0.1:${PORT}`; @@ -50,16 +51,35 @@ test.beforeAll(async () => { test.afterAll(() => { server?.kill(); }); -async function loginAndOpen(page) { +async function loginAndOpen(page, projectId = '1') { await page.goto(`${BASE}/`); - await page.evaluate(([t]) => { + await page.evaluate(([t, id]) => { localStorage.setItem('scopeweave:token', t); - localStorage.setItem('scopeweave:project', '1'); - }, [token]); + localStorage.setItem('scopeweave:project', id); + }, [token, projectId]); await page.reload(); await page.waitForSelector('#cloud-auth select'); } +test('opening a cloud project clears standalone seed onboarding', async ({ page }) => { + await page.goto(`${BASE}/`); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + await page.getByRole('button', { name: /클라우드 로그인/ }).click(); + await page.fill('#cloud-email', 'e2e@cloud.com'); + await page.fill('#cloud-password', 'password123'); + await page.click('#cloud-submit'); + await page.waitForSelector('#cloud-auth select'); + await page.getByRole('searchbox', { name: 'WBS 작업 검색' }).fill('사업수행계획'); + await page.locator('#cloud-auth select').selectOption('1'); + + await expect(page.locator('#seed-onboarding')).toBeHidden(); + await expect(page.locator('#clear-seed-data')).toBeHidden(); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toHaveValue(''); + const project = await api('/api/projects/1', { tok: token }); + expect(project.tasks.length).toBeGreaterThan(0); +}); + test('cloud bar renders the full toolset when logged in', async ({ page }) => { await loginAndOpen(page); const labels = await page.$$eval('#cloud-auth button', (bs) => bs.map((b) => b.textContent)); @@ -76,6 +96,21 @@ test('workload table aggregates per owner with behind highlight', async ({ page expect(rows.some((r) => r.includes('이담당'))).toBeTruthy(); }); +test('cloud task names are searchable and extended JSON backups retain progress', async ({ page }) => { + await loginAndOpen(page); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('설계'); + await expect(page.locator('tbody tr[data-task-id="t1"]')).toHaveCount(1); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'JSON 내보내기' }).click(), + ]); + const backup = JSON.parse(await readFile(await download.path(), 'utf8')); + const task = backup.find((row) => row.name === '설계'); + expect(task?.actualProgress).toBe(40); +}); + test('baseline: save then compare reports no diff', async ({ page }) => { await loginAndOpen(page); page.on('dialog', (d) => d.accept('착수 기준선')); @@ -89,10 +124,14 @@ test('baseline: save then compare reports no diff', async ({ page }) => { test('comments: post appears in the list with author', async ({ page }) => { await loginAndOpen(page); await page.click('#cloud-auth button:has-text("코멘트")'); + await page.locator('#comments-panel select').selectOption('t1'); await page.fill('#comments-panel input[type="text"]', '일정 확인 부탁드립니다'); await page.click('#comments-panel button:has-text("등록")'); await expect(page.locator('#comments-panel .team-list')).toContainText('e2e@cloud.com'); + await expect(page.locator('#comments-panel .team-list')).toContainText('[설계]'); await expect(page.locator('#comments-panel .team-list')).toContainText('일정 확인 부탁드립니다'); + await page.click('#comments-panel .team-list button:has-text("삭제")'); + await expect(page.locator('#comments-panel .team-list')).toContainText('코멘트가 없습니다.'); }); test('portfolio dashboard: rollup renders SPI/status per project', async ({ page }) => { @@ -137,6 +176,8 @@ test('share link: anonymous visitor gets a read-only view; revoke kills it', asy test('MSP import: XML file populates the tree and saves to the cloud', async ({ page }) => { await loginAndOpen(page); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('설계'); page.on('dialog', (d) => d.accept()); await page.click('#cloud-auth button:has-text("MSP 가져오기")'); const xml = ` @@ -144,6 +185,7 @@ test('MSP import: XML file populates the tree and saves to the cloud', async ({ 2MSP액티비티22026-03-02T08:00:002026-03-06T17:00:00 `; await page.setInputFiles('#msp-file-input', { name: 'plan.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) }); + await expect(search).toHaveValue(''); await page.waitForFunction(() => document.querySelector('#task-table-body')?.textContent.includes('MSP단계')); // wait for the debounced cloud push, then confirm server state await page.waitForTimeout(1200); @@ -160,3 +202,193 @@ test('archive: project moves under the 보관됨 optgroup and restores', async ( await page.click('#cloud-auth button:has-text("보관 해제")'); await page.waitForFunction(() => !document.querySelector('#cloud-auth select optgroup[label="보관됨"]')); }); + +test('cloud management modals cover share, sprint, attachment, and search flows', async ({ page }) => { + const project = await api('/api/projects', { method: 'POST', body: { name: '모달 프로젝트' }, tok: token }); + await api(`/api/projects/${project.id}`, { + method: 'PUT', + tok: token, + body: { + tasks: [{ + id: 'modal-task', + name: '모달 작업', + sprint: '모달 스프린트', + storyPoints: 5, + plannedStartDate: '2026-01-01', + plannedEndDate: '2026-01-07', + actualProgress: 100, + }], + version: project.version, + }, + }); + await api(`/api/projects/${project.id}/sprints`, { + method: 'POST', + tok: token, + body: { name: '모달 스프린트', startDate: '2026-01-01', endDate: '2026-01-07' }, + }); + + await loginAndOpen(page, String(project.id)); + page.on('dialog', (dialog) => dialog.accept('')); + + await page.click('#cloud-auth button:has-text("공유")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + await page.click('#share-panel button:has-text("공유 링크 만들기")'); + await expect(page.locator('#share-panel')).toContainText('?share='); + await page.click('#share-panel button:has-text("복사")'); + await page.click('#share-panel button:has-text("철회")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + await page.click('#share-panel button[aria-label="공유 닫기"]'); + + await page.click('#cloud-auth button:has-text("스프린트")'); + await expect(page.locator('#sprint-panel')).toContainText('모달 스프린트'); + await page.selectOption('#methodology-select', 'hybrid'); + await page.click('#sprint-panel button:has-text("번다운")'); + await expect(page.locator('#burndown-holder svg')).toBeVisible(); + await page.locator('#sprint-panel input[placeholder*="스프린트 이름"]').fill('추가 스프린트'); + await page.locator('#sprint-panel input[type="date"]').nth(0).fill('2026-02-01'); + await page.locator('#sprint-panel input[type="date"]').nth(1).fill('2026-02-07'); + await page.click('#sprint-panel button:has-text("추가")'); + await expect(page.locator('#sprint-panel')).toContainText('추가 스프린트'); + const addedSprint = page.locator('#sprint-panel li').filter({ hasText: '추가 스프린트' }); + await addedSprint.getByRole('button', { name: '삭제' }).click(); + await expect(addedSprint).toHaveCount(0); + await page.click('#sprint-panel button[aria-label="스프린트 닫기"]'); + + await page.click('#cloud-auth button:has-text("산출물")'); + await expect(page.locator('#attachments-panel')).toContainText('첨부된 산출물이 없습니다.'); + await page.locator('#attachments-panel select').selectOption('modal-task'); + await page.setInputFiles('#attachment-file-input', { name: 'brief.txt', mimeType: 'text/plain', buffer: Buffer.from('e2e artifact') }); + await page.click('#attachments-panel button:has-text("업로드")'); + await expect(page.locator('#attachments-panel .team-list')).toContainText('brief.txt'); + const popupPromise = page.waitForEvent('popup'); + await page.click('#attachments-panel button:has-text("보기")'); + const popup = await popupPromise; + await popup.waitForLoadState('domcontentloaded'); + expect(new URL(popup.url()).pathname).toContain('/api/mock-clearfolio/'); + await popup.close(); + await page.click('#attachments-panel button:has-text("삭제")'); + await expect(page.locator('#attachments-panel')).toContainText('첨부된 산출물이 없습니다.'); + await page.click('#attachments-panel button[aria-label="산출물 닫기"]'); + + await page.click('#cloud-auth button:has-text("검색")'); + await page.locator('#search-panel input[type="search"]').fill('모달 작업'); + await page.click('#search-panel button:has-text("검색")'); + await expect(page.locator('#search-panel')).toContainText('모달 프로젝트'); + await page.click('#search-panel button:has-text("열기")'); + await expect(page.locator('#cloud-auth select')).toHaveValue(String(project.id)); +}); + +test('team management covers owner, member, billing, token, webhook, and audit flows', async ({ page }) => { + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("팀")'); + await expect(page.locator('#team-body')).toContainText('e2e@cloud.com'); + + page.once('dialog', (dialog) => dialog.accept('E2E 워크스페이스')); + await page.click('#team-body button:has-text("워크스페이스 이름 변경")'); + await expect(page.locator('#team-body')).toContainText('Free'); + await page.click('#team-body button:has-text("Pro 업그레이드")'); + await expect(page.locator('#toast')).toContainText('결제 연동'); + + await page.fill('#team-email', 'cancelled@cloud.com'); + await page.locator('#team-email').press('Enter'); + await expect(page.locator('#team-body')).toContainText('cancelled@cloud.com'); + await page.locator('#team-body li').filter({ hasText: 'cancelled@cloud.com' }).getByRole('button', { name: '초대 취소' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('cancelled@cloud.com'); + + await page.fill('#team-email', 'member@cloud.com'); + await page.locator('#team-email').press('Enter'); + const pending = await api('/api/orgs/1/members', { tok: token }); + const invite = pending.invites.find((row) => row.email === 'member@cloud.com'); + expect(invite?.token).toBeTruthy(); + const member = await api('/api/auth/signup', { method: 'POST', body: { email: 'member@cloud.com', password: 'password123' } }); + await api(`/api/invites/${invite.token}/accept`, { method: 'POST', tok: member.token }); + + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const memberRow = page.locator('#team-body .team-list li').filter({ hasText: 'member@cloud.com' }); + await expect(memberRow).toBeVisible(); + await memberRow.getByRole('combobox').selectOption('viewer'); + page.once('dialog', (dialog) => dialog.dismiss()); + await memberRow.getByRole('button', { name: '소유권 이전' }).dispatchEvent('click'); + await memberRow.getByRole('button', { name: '제거' }).dispatchEvent('click'); + await expect(memberRow).toHaveCount(0); + + await page.fill('#team-body input[placeholder="https://example.com/webhook"]', 'https://example.com/hook'); + await page.locator('#team-body input[placeholder="https://example.com/webhook"]').press('Enter'); + await expect(page.locator('#team-body')).toContainText('서명 시크릿(한 번만 표시): whsec_'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const webhookRow = page.locator('#team-body .team-list li').filter({ hasText: 'https://example.com/hook' }); + let rotateDialogs = 0; + const handleRotateDialog = (dialog) => { + rotateDialogs += 1; + dialog.accept(''); + if (rotateDialogs === 2) page.off('dialog', handleRotateDialog); + }; + page.on('dialog', handleRotateDialog); + await webhookRow.getByRole('button', { name: '키 교체' }).dispatchEvent('click'); + await webhookRow.getByRole('button', { name: '삭제' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('https://example.com/hook'); + + await page.fill('#team-body input[placeholder*="토큰 이름"]', 'CI'); + await page.locator('#team-body input[placeholder*="토큰 이름"]').press('Enter'); + await expect(page.locator('#team-body')).toContainText('한 번만 표시됩니다'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const tokenRow = page.locator('#team-body .team-list li').filter({ hasText: 'CI' }); + await tokenRow.getByRole('button', { name: '폐기' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('CI'); + + const auditDownload = page.waitForEvent('download'); + await page.locator('#team-body button:has-text("CSV 다운로드")').dispatchEvent('click'); + expect((await auditDownload).suggestedFilename()).toContain('scopeweave-audit-'); + const exportDownload = page.waitForEvent('download'); + await page.locator('#team-body button:has-text("데이터 내보내기")').dispatchEvent('click'); + expect((await exportDownload).suggestedFilename()).toContain('scopeweave-org-'); + + await page.fill('#team-body input[placeholder="현재 비밀번호"]', 'password123'); + await page.fill('#team-body input[placeholder*="새 비밀번호"]', 'password456'); + await page.locator('#team-body input[placeholder*="새 비밀번호"]').press('Enter'); + await expect(page.locator('#toast')).toContainText('비밀번호를 변경했습니다'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#team-body button:has-text("다른 모든 기기에서 로그아웃")').dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('다른 모든 기기에서 로그아웃했습니다'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("로그아웃")'); + await expect(page.getByRole('button', { name: /클라우드 로그인/ })).toBeVisible(); +}); + +test('cloud project creation prompt creates a project for a new account', async ({ page }) => { + const creator = await api('/api/auth/signup', { + method: 'POST', + body: { email: 'creator@cloud.com', password: 'password123' }, + }); + await page.goto(`${BASE}/`); + await page.evaluate((t) => { + localStorage.setItem('scopeweave:token', t); + localStorage.removeItem('scopeweave:project'); + }, creator.token); + await page.reload(); + page.once('dialog', (dialog) => dialog.accept('UI 생성 프로젝트')); + await page.click('#cloud-auth button:has-text("+ 새 프로젝트")'); + await expect(page.locator('#cloud-auth select')).toContainText('UI 생성 프로젝트'); +}); + +test('cloud onboarding creates the first project from the sample', async ({ page }) => { + const sampleUser = await api('/api/auth/signup', { + method: 'POST', + body: { email: 'sample@cloud.com', password: 'password123' }, + }); + await page.goto(`${BASE}/`); + await page.evaluate((t) => { + localStorage.setItem('scopeweave:token', t); + localStorage.removeItem('scopeweave:project'); + }, sampleUser.token); + await page.reload(); + await page.getByRole('button', { name: '✨ 샘플로 시작' }).click(); + await expect(page.locator('#cloud-auth select')).toContainText('샘플 프로젝트'); +}); diff --git a/tests/e2e/coverage-fixtures.js b/tests/e2e/coverage-fixtures.js new file mode 100644 index 00000000..b00fd2b7 --- /dev/null +++ b/tests/e2e/coverage-fixtures.js @@ -0,0 +1,27 @@ +import { test as base } from '@playwright/test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const coverageEnabled = process.env.SCOPEWEAVE_BROWSER_COVERAGE === '1'; +const coverageDirectory = path.resolve('coverage/browser'); + +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + if (!coverageEnabled) { + await use(page); + return; + } + + await page.coverage.startJSCoverage({ reportAnonymousScripts: false }); + try { + await use(page); + } finally { + const entries = await page.coverage.stopJSCoverage(); + await mkdir(coverageDirectory, { recursive: true }); + const filename = `${testInfo.workerIndex}-${testInfo.testId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`; + await writeFile(path.join(coverageDirectory, filename), JSON.stringify(entries)); + } + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..6cc86ed2 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import fc from 'fast-check'; test.describe('CSV formula fuzzing', () => { diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..c3a7047f 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import fs from 'node:fs'; @@ -76,8 +76,10 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('link[rel="modulepreload"][href="cloud-sync.js"]')).toHaveCount(1); await expect(page.locator('link[rel="modulepreload"][href="analytics.js"]')).toHaveCount(1); await expect(page.locator('link[rel="modulepreload"][href="app.js"]')).toHaveCount(1); - await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); + await expect(page.locator('#add-root-task')).toBeVisible(); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + await expect(page.locator('#seed-onboarding')).toContainText('샘플 WBS가 준비되어 있습니다'); await expect(page.getByTestId('project-name-input')).toHaveValue(/ScopeWeave/i); await expect(page.getByTestId('summary-total-days')).not.toHaveText('0일'); await expect(page.getByTestId('summary-planned-progress')).toContainText('%'); @@ -90,6 +92,90 @@ test.describe('ScopeWeave Planner', () => { await expect(page).toHaveTitle('My New Project - ScopeWeave Planner'); }); + test('remembers hiding the first-run sample onboarding notice', async ({ page }) => { + const onboarding = page.locator('#seed-onboarding'); + + await expect(onboarding).toBeVisible(); + await expect(onboarding).toHaveAttribute('aria-labelledby', 'seed-onboarding-title'); + await expect(onboarding).toHaveAttribute('aria-describedby', 'seed-onboarding-description'); + await onboarding.getByRole('button', { name: '안내 숨기기' }).click(); + await expect(onboarding).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + + await page.reload(); + await expect(onboarding).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + }); + + test('keeps seed onboarding visible when persistence fails', async ({ page }) => { + await page.evaluate(() => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function setItem(key, value) { + if (key === 'scopeweave:planner-state:v1') { + throw new DOMException('quota exceeded', 'QuotaExceededError'); + } + return originalSetItem.call(this, key, value); + }; + }); + + await page.getByTestId('project-name-input').fill('Unsaved sample edit'); + await expect(page.locator('#toast')).toContainText('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + page.once('dialog', dialog => dialog.accept()); + await page.locator('#clear-seed-data').click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + }); + + test('clears the first-run sample into an empty plan', async ({ page }) => { + page.once('dialog', dialog => dialog.dismiss()); + await page.locator('#clear-seed-data').click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + page.once('dialog', dialog => dialog.accept()); + + await page.locator('#clear-seed-data').click(); + + await expect(page.locator('#seed-onboarding')).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + await expect(page.locator('.table-empty')).toContainText('등록된 작업이 없습니다'); + await expect(page.locator('#toast')).toContainText('샘플 데이터를 삭제했습니다.'); + await expect(page.locator('#add-root-task')).toBeVisible(); + const savedTasks = await page.evaluate(() => JSON.parse(localStorage.getItem('scopeweave:planner-state:v1')).tasks); + expect(savedTasks).toEqual([]); + }); + + test('filters WBS rows while preserving matching task hierarchy context', async ({ page }) => { + const rows = page.locator('tbody tr[data-task-id]'); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + + await expect(rows).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + await search.fill('단계작업계획'); + + await expect(rows).toHaveCount(3); + await expect(page.locator('#seed-onboarding')).toBeHidden(); + await expect(rows).toContainText(['P0000.준비단계', '프로젝트준비', '단계작업계획']); + await expect(rows.filter({ hasText: '사업수행계획' })).toHaveCount(0); + const contextToggle = rows.first().locator('button[data-action="toggle"]'); + await expect(contextToggle).toHaveAttribute('aria-disabled', 'true'); + await expect(contextToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); + await contextToggle.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중 계층 맥락 고정'); + await search.fill(''); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + await search.fill('없는작업'); + await expect(rows).toHaveCount(0); + await expect(page.locator('.table-empty')).toContainText('검색 결과가 없습니다'); + + await page.getByRole('button', { name: '검색 지우기' }).click(); + await expect(rows).toHaveCount(4); + }); + [ { name: 'desktop', width: 1440, height: 1000 }, { name: 'mobile', width: 375, height: 667 } @@ -185,8 +271,36 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('.table-empty').getByRole('button', { name: 'CSV 가져오기' })).toBeVisible(); await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('title', '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + await expect(page.getByRole('button', { name: 'JSON 내보내기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('title', '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); + + await page.getByRole('button', { name: 'CSV 내보내기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('내보낼 작업이 없습니다'); + await page.getByRole('button', { name: 'JSON 내보내기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('내보낼 작업이 없습니다'); + await page.getByRole('button', { name: '간트차트보기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('간트 차트로 표시할 작업이 없습니다'); + + const chooserPromise = page.waitForEvent('filechooser'); + await page.locator('.table-empty').getByRole('button', { name: 'CSV 가져오기' }).click(); + await chooserPromise; + + await page.locator('.table-empty').getByRole('button', { name: '최상위 작업 추가' }).click(); + await expect(page.locator('.editor-panel')).toBeVisible(); + await page.locator('.editor-panel').getByRole('button', { name: '취소' }).click(); + await expect(page.locator('.table-empty')).toBeVisible(); + }); + + test('reports the native file-sync fallback when the picker is unavailable', async ({ page }) => { + await page.addInitScript(() => { delete window.showSaveFilePicker; }); + await page.reload(); + + const syncButton = page.locator('#connect-json-sync'); + await expect(syncButton).toHaveAttribute('aria-disabled', 'true'); + await expect(syncButton).toHaveAttribute('title', '이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'); + await syncButton.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'); }); test('keeps the empty WBS state inside the mobile table viewport', async ({ page }) => { @@ -248,6 +362,11 @@ test.describe('ScopeWeave Planner', () => { await expect(parentToggle).toHaveAttribute('aria-label', '접기 - P0000.준비단계'); await expect(parentToggle).toHaveAttribute('title', '접기 - P0000.준비단계'); + await parentToggle.click(); + await expect(childRow).toHaveCount(0); + await parentToggle.click(); + await expect(childRow).toHaveCount(1); + await childRow.getByRole('button', { name: '하위 추가' }).click(); await page.locator('[data-testid="editor-task"]').fill('세부업무'); await page.getByRole('button', { name: '저장', exact: true }).click(); @@ -883,6 +1002,13 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('#task-table-body')).not.toContainText(overlongTaskName.substring(0, 1000)); }); + test('rejects CSV files larger than the browser import limit', async ({ page }) => { + await importCsv(page, Buffer.alloc(5 * 1024 * 1024 + 1, 65).toString('utf8')); + + await expect(page.locator('#toast')).toContainText('파일 크기는 5MB를 초과할 수 없습니다.'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + }); + test('normalizes imported task rows into a full phase-activity-task hierarchy', async ({ page }) => { await importCsv(page, ['단계,Activity,Task,대분류,중분류,산출물,담당자,지원팀,진행상태,계획시작일,계획종료일,일수,계획진척률,가중치,가중치진척률,실적진척상태,실적진척률,실적시작일,실적종료일,가중치실적진척률', 'P4000.이행단계,,고아Task,이행,,,담당자A,,,2026-06-01,2026-06-03,,,미착수(0%),,,'].join('\n')); @@ -981,6 +1107,79 @@ test.describe('ScopeWeave Planner', () => { expect(csvText).toContain(`"'|'cmd' /C calc'!A0"`); }); + test('can trigger JSON export download in the seed contract', async ({ page }, testInfo) => { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'JSON 내보내기' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^wbs_export_\d{8}\.json$/); + const downloadPath = await download.path(); + const jsonPath = downloadPath || testInfo.outputPath(download.suggestedFilename()); + if (!downloadPath) { + await download.saveAs(jsonPath); + } + const exported = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + expect(exported).toHaveLength(2); + expect(exported[1]).toMatchObject({ + task: '단계작업계획', + plannedEndDate: '2026-05-15', + plannedEndDdate: '2026-05-15' + }); + expect(exported[0]).not.toHaveProperty('id'); + }); + + test('preserves extended planning fields in JSON download', async ({ page }, testInfo) => { + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Extended JSON', + baseDate: '2026-05-01', + tasks: [{ + id: 'extended-1', + parentId: null, + depth: 1, + expanded: true, + isSynthetic: false, + phase: 'P1000.계획', + activity: '', + task: '', + categoryLarge: '', + categoryMedium: '', + documentName: '', + owner: '', + supportTeam: '', + plannedStartDate: '2026-05-01', + plannedEndDate: '2026-05-05', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: 'P2000', + budget: '1000', + actualCost: '200', + sprint: 'S1', + storyPoints: '5' + }] + })); + }); + await page.reload(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'JSON 내보내기' }).click(); + const download = await downloadPromise; + const downloadPath = await download.path(); + const jsonPath = downloadPath || testInfo.outputPath(download.suggestedFilename()); + if (!downloadPath) { + await download.saveAs(jsonPath); + } + const [exported] = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + expect(exported).toMatchObject({ + predecessors: 'P2000', + budget: '1000', + actualCost: '200', + sprint: 'S1', + storyPoints: '5' + }); + }); + test('can trigger CSV import file chooser', async ({ page }) => { const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'CSV 가져오기' }).click(); @@ -1091,6 +1290,7 @@ test.describe('ScopeWeave Planner', () => { }); test('renders empty cells as independent DOM clones', async ({ page }) => { + await expect(page.locator('.empty-cell').nth(1)).toBeAttached(); const result = await page.evaluate(() => { const emptyCells = Array.from(document.querySelectorAll('.empty-cell')); const [first, second] = emptyCells; diff --git a/tests/e2e/test_getTaskSubtreeRange.spec.js b/tests/e2e/test_getTaskSubtreeRange.spec.js index bc1eb38a..d8f799e5 100644 --- a/tests/e2e/test_getTaskSubtreeRange.spec.js +++ b/tests/e2e/test_getTaskSubtreeRange.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test.describe('getTaskSubtreeRange function tests', () => { test('should return correct range for root task, sub task and non-existent task', async ({ page }) => { diff --git a/tests/e2e/toast-accessibility.spec.js b/tests/e2e/toast-accessibility.spec.js index 5e45cb79..9d7d8fe9 100644 --- a/tests/e2e/toast-accessibility.spec.js +++ b/tests/e2e/toast-accessibility.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test('cloud status feedback is visibly rendered as a non-focus-taking live status', async ({ page }) => { await page.goto('/?share=ABCDEFGHIJKLMNOP'); diff --git a/tests/e2e/visual-accessibility-evidence.spec.js b/tests/e2e/visual-accessibility-evidence.spec.js new file mode 100644 index 00000000..ae93d27c --- /dev/null +++ b/tests/e2e/visual-accessibility-evidence.spec.js @@ -0,0 +1,70 @@ +import { test, expect } from './coverage-fixtures.js'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +function luminance([red, green, blue]) { + const channels = [red, green, blue].map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]); +} + +function contrastRatio(foreground, background) { + const foregroundLuminance = luminance(foreground); + const backgroundLuminance = luminance(background); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) + / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); +} + +function parseRgb(value) { + const match = value.match(/rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)/i); + return match ? match.slice(1, 4).map(Number) : null; +} + +test('captures core planner states with WCAG 2.2 baseline evidence', async ({ page }, testInfo) => { + await page.goto('./'); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('.seed-onboarding')).toBeVisible(); + await expect(page.locator('main#main-content')).toHaveAttribute('tabindex', '-1'); + await expect(page.locator('a.skip-link')).toHaveAttribute('href', '#main-content'); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })) + .toHaveAttribute('aria-controls', 'task-table-body'); + await expect(page.locator('th[scope="col"]')).toHaveCount(21); + + const contrast = await page.locator('body').evaluate((element) => ({ + foreground: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + })); + const foreground = parseRgb(contrast.foreground); + const background = parseRgb(contrast.background); + expect(foreground, `expected body foreground color, got ${contrast.foreground}`).not.toBeNull(); + expect(background, `expected body background color, got ${contrast.background}`).not.toBeNull(); + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5); + + const samplePath = testInfo.outputPath('scopeweave-sample.png'); + await page.screenshot({ path: samplePath, fullPage: true }); + await testInfo.attach('scopeweave-sample', { path: samplePath, contentType: 'image/png' }); + + await page.keyboard.press('Tab'); + await expect(page.locator('a.skip-link')).toBeFocused(); + const focusPath = testInfo.outputPath('scopeweave-skip-link-focus.png'); + await page.screenshot({ path: focusPath, fullPage: true }); + await testInfo.attach('scopeweave-skip-link-focus', { path: focusPath, contentType: 'image/png' }); + + await page.evaluate((storageKey) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'Empty visual evidence', + baseDate: '2026-08-29', + tasks: [], + })); + }, STORAGE_KEY); + await page.reload(); + await expect(page.locator('.empty-state-cell')).toBeVisible(); + await expect(page.locator('.empty-state-cell').getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); + + const emptyPath = testInfo.outputPath('scopeweave-empty.png'); + await page.screenshot({ path: emptyPath, fullPage: true }); + await testInfo.attach('scopeweave-empty', { path: emptyPath, contentType: 'image/png' }); +}); diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js new file mode 100644 index 00000000..02e1e9f3 --- /dev/null +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -0,0 +1,109 @@ +import { test, expect } from './coverage-fixtures.js'; + +test.describe('WBS search interaction safety', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./'); + }); + + test('prevents drag reordering while filtered rows hide sibling context', async ({ page }) => { + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('단계작업계획'); + + const rows = page.locator('tbody tr[data-task-id]'); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toHaveAttribute('draggable', 'false'); + await expect(rows.nth(1)).toHaveAttribute('draggable', 'false'); + await expect(rows.nth(2)).toHaveAttribute('draggable', 'false'); + }); + + test('blocks hierarchy changes while filtered rows hide context', async ({ page }) => { + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('단계작업계획'); + + const rows = page.locator('tbody tr[data-task-id]'); + const addRoot = page.getByRole('button', { name: '최상위 작업 추가' }); + const addChild = rows.first().getByRole('button', { name: /하위 추가 -/ }); + const deleteButton = rows.first().getByRole('button', { name: /삭제 -/ }); + + await expect(addRoot).toHaveAttribute('aria-disabled', 'true'); + const toggle = rows.first().locator('button[data-action="toggle"]'); + await expect(toggle).toHaveAttribute('aria-disabled', 'true'); + await toggle.focus(); + await expect(toggle).toBeFocused(); + await toggle.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중 계층 맥락 고정'); + await expect(addChild).toHaveAttribute('aria-disabled', 'true'); + await expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + + await addChild.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + + await addRoot.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + + await deleteButton.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 삭제할 수 없습니다.'); + await expect(rows).toHaveCount(3); + }); + + test('searches planning values and clears the query when CSV replaces the plan', async ({ page }) => { + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Planning search', + baseDate: '2026-08-29', + tasks: [{ + id: 'planning-search-task', + parentId: null, + depth: 3, + expanded: true, + phase: '계획', + activity: '검색', + task: '계획 값', + budget: '125000', + actualCost: '90000', + storyPoints: '13' + }] + })); + }); + await page.reload(); + + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + const rows = page.locator('tbody tr[data-task-id]'); + for (const query of ['125000', '90000', '13']) { + await search.fill(query); + await expect(rows).toHaveCount(1); + } + + page.once('dialog', dialog => dialog.accept()); + await page.locator('#csv-file-input').setInputFiles({ + name: 'replacement.csv', + mimeType: 'text/csv', + buffer: Buffer.from([ + '단계,Activity,Task,대분류,중분류,산출물,담당자,지원팀,진행상태,계획시작일,계획종료일,일수,계획진척률,가중치,가중치진척률,실적진척상태,실적진척률,실적시작일,실적종료일,가중치실적진척률,__id,__parentId,__depth,선행작업,예산,실투입비,스프린트,스토리포인트', + '교체 단계,,,,,,,,,,,,,,미착수(0%),,,,,replacement-task,,,3,,,,,' + ].join('\n')) + }); + await expect(search).toHaveValue(''); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText('교체 단계'); + }); + + test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { + const firstRow = page.locator('tbody tr[data-task-id]').first(); + await firstRow.getByRole('button', { name: /편집 -/ }).click(); + + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toBeDisabled(); + }); + + test('keeps the depth-limit explanation actionable when search is inactive', async ({ page }) => { + const leafAddChild = page.locator('tbody tr[data-task-id].depth-3').first().locator('button[data-action="add-child"]'); + + await expect(leafAddChild).toHaveAttribute('aria-disabled', 'true'); + await leafAddChild.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다.'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + }); +}); diff --git a/tests/e2e/wbs-search-persistence.spec.js b/tests/e2e/wbs-search-persistence.spec.js new file mode 100644 index 00000000..983c7b69 --- /dev/null +++ b/tests/e2e/wbs-search-persistence.spec.js @@ -0,0 +1,30 @@ +import { test, expect } from './coverage-fixtures.js'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +test('keeps WBS search ephemeral across persistence and reload', async ({ page }) => { + await page.goto('./'); + + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + const rows = page.locator('tbody tr[data-task-id]'); + + await expect(rows).toHaveCount(4); + await page.getByTestId('project-name-input').fill('Search persistence'); + await expect.poll(async () => page.evaluate((storageKey) => localStorage.getItem(storageKey), STORAGE_KEY)).not.toBeNull(); + + await search.fill('단계작업계획'); + await expect(rows).toHaveCount(3); + + const persistedState = await page.evaluate((storageKey) => { + const raw = localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : null; + }, STORAGE_KEY); + + expect(persistedState).not.toBeNull(); + expect(persistedState).not.toHaveProperty('taskQuery'); + + await page.reload(); + + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toHaveValue(''); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); +}); diff --git a/tests/unit/cloud-sync-metrics.test.mjs b/tests/unit/cloud-sync-metrics.test.mjs new file mode 100644 index 00000000..54319d14 --- /dev/null +++ b/tests/unit/cloud-sync-metrics.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; + +import { compareBaseline, computeBurndown, computeSprintStats } from '../../cloud-sync.js'; + +const tasks = [ + { id: 'done', sprint: 'S1', storyPoints: 3, actualProgress: 100 }, + { id: 'doing', sprint: 'S1', storyPoints: 2, actualProgress: 50 }, + { id: 'closed', sprint: 'S2', storyPoints: 5, actualProgress: 100 }, + { id: 'backlog', storyPoints: 1, actualProgress: 0 }, + { id: 'unknown', sprint: 'S9', storyPoints: 1, actualProgress: 0 }, + { id: 'synthetic', sprint: 'S1', storyPoints: 99, isSynthetic: true }, +]; + +const sprintStats = computeSprintStats(tasks, [ + { id: 's1', name: 'S1', startDate: '2026-01-01', endDate: '2026-01-05', goal: 'ship' }, + { id: 's2', name: 'S2', startDate: '2026-01-06', endDate: '2026-01-20', goal: 'polish' }, +], '2026-01-10'); +assert.deepEqual(sprintStats.rows.map(({ name, committed, completed, remaining, closed }) => ({ + name, committed, completed, remaining, closed, +})), [ + { name: 'S1', committed: 5, completed: 3, remaining: 2, closed: true }, + { name: 'S2', committed: 5, completed: 5, remaining: 0, closed: false }, +]); +assert.equal(sprintStats.velocity, 3); +assert.equal(sprintStats.backlogCount, 2); +assert.deepEqual(computeSprintStats(), { rows: [], velocity: null, backlogCount: 0 }); + +assert.equal(computeBurndown(tasks, null, '2026-01-02'), null); +assert.equal(computeBurndown(tasks, { name: 'S1', startDate: '2026-01-03', endDate: '2026-01-02' }, '2026-01-02'), null); +assert.equal(computeBurndown([{ sprint: 'S1', storyPoints: 0 }], { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-02' }, '2026-01-02'), null); + +const burndown = computeBurndown([ + { sprint: 'S1', storyPoints: 2, actualEndDate: '2026-01-01' }, + { sprint: 'S1', storyPoints: 1, actualProgress: 100 }, + { sprint: 'S1', storyPoints: 2, actualProgress: 20 }, +], { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-03' }, '2026-01-02'); +assert.deepEqual(burndown.days, ['2026-01-01', '2026-01-02', '2026-01-03']); +assert.deepEqual(burndown.ideal, [5, 2.5, 0]); +assert.deepEqual(burndown.actual, [3, 2, null]); + +const oneDay = computeBurndown( + [{ sprint: 'S1', storyPoints: 1, actualProgress: 100 }], + { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-01' }, + '2026-01-01', +); +assert.deepEqual(oneDay.ideal, [0]); +assert.deepEqual(oneDay.actual, [0]); + +const capped = computeBurndown( + [{ sprint: 'S1', storyPoints: 1 }], + { name: 'S1', startDate: '2026-01-01', endDate: '2026-05-01' }, + '2026-01-01', +); +assert.equal(capped.days.length, 121); + +const comparison = compareBaseline([ + { id: 'same', name: '같음', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'moved', name: '이동', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'removed', name: '삭제' }, + { id: 'invalid', name: '잘못됨', plannedStartDate: 'invalid' }, +], [ + { id: 'same', name: '같음', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'moved', name: '이동', plannedStartDate: '2026-01-02', plannedEndDate: '2026-01-12' }, + { id: 'added', name: '추가' }, + { id: 'invalid', name: '잘못됨', plannedStartDate: '2026-01-02' }, +]); +assert.deepEqual(comparison.rows, [ + { id: 'moved', name: '이동', kind: 'moved', baseEnd: '2026-01-10', curEnd: '2026-01-12', endSlip: 2 }, + { id: 'added', name: '추가', kind: 'added', endSlip: null }, + { id: 'removed', name: '삭제', kind: 'removed', endSlip: null }, +]); +assert.deepEqual(comparison.summary, { changed: 3, slipped: 1, maxSlip: 2 }); +assert.deepEqual(compareBaseline(), { rows: [], summary: { changed: 0, slipped: 0, maxSlip: 0 } }); + +console.log('✓ cloud-sync metric tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..3d6e116a 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -8,6 +8,14 @@ const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); const scripts = packageJson.scripts; +const runBrowserCoverage = readFileSync( + new URL('../../scripts/ci/run-browser-coverage.mjs', import.meta.url), + 'utf8', +); +const mergeBrowserCoverage = readFileSync( + new URL('../../scripts/ci/merge-browser-coverage.mjs', import.meta.url), + 'utf8', +); assert.equal( scripts.coverage, @@ -16,28 +24,40 @@ assert.equal( ); assert.match( scripts['test:coverage'], + /test:coverage:node.*run-browser-coverage.*merge-browser-coverage/, + 'test:coverage runs Node and browser coverage before merging reports', +); +assert.match( + scripts['test:coverage:node'], /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, - 'test:coverage creates Istanbul JSON before executing coverage cases', + 'the Node coverage producer creates Istanbul JSON before executing cases', ); assert.match( - scripts['test:coverage'], + scripts['test:coverage:node'], /--reporter=json-summary\b/, - 'test:coverage also creates the Istanbul JSON summary', + 'the Node coverage producer also creates the Istanbul JSON summary', ); assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', + scripts['test:coverage:node'], + /--include=["']server\/\*\.mjs["']/, + 'all server modules are instrumented', ); -assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', +assert.match(runBrowserCoverage, /fileURLToPath\(new URL\(/, 'browser coverage resolves file URLs safely'); +assert.match(mergeBrowserCoverage, /fileURLToPath\(new URL\(/, 'merged coverage resolves file URLs safely'); +assert.equal( + scripts['test:coverage:strict'], + 'npm run test:coverage && node scripts/ci/check-coverage.mjs', + 'the strict command checks the merged coverage summary', ); assert.match( scripts['test:coverage:cases'], + /npm run test:unit.*npm run test:api/, + 'the complete unit and API suites execute under c8', +); +assert.match( + scripts['test:unit'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression executes under c8', + 'the Clearfolio signal and HTTP failure regression remains in the unit suite', ); assert.doesNotMatch( scripts['test:coverage:cases'],