diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index f69e8d68..3d22d9e6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -44,7 +44,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: pnpm install - name: Select iteration budget id: budget @@ -63,4 +63,4 @@ jobs: # Bounded per-property iteration count; the 10-minute job timeout is # the hard ceiling. PRs stay short (~60-120s total), nightly digs deeper. FUZZ_RUNS: ${{ steps.budget.outputs.runs }} - run: npm run fuzz + run: pnpm run fuzz diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index d82dd23b..e78e1df5 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -30,11 +30,11 @@ jobs: with: node-version: 22 - name: Install - run: npm ci + run: pnpm install - name: Unit tests (EVM · CPM · baseline · workload) - run: npm run test:unit + run: pnpm run test:unit - name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit) - run: npm run test:api + run: pnpm run test:api - name: app.js stays eval-safe (no top-level import/export) run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')" @@ -50,8 +50,8 @@ jobs: with: node-version: 22 - name: Install - run: npm ci + run: pnpm install - name: Install Playwright (chromium) run: npx playwright install chromium --with-deps - name: Cloud UI e2e - run: npm run test:e2e:cloud + run: pnpm run test:e2e:cloud diff --git a/.jules/palette.md b/.jules/palette.md index 0bbf5248..ae36bc5f 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -108,10 +108,14 @@ **Learning:** [When an element is removed from the DOM, focus naturally resets to the document body, breaking the keyboard navigation flow. It is critical to calculate the next logical focus target prior to deletion and programmatically restore focus post-render.] **Action:** [In future components involving item deletion within lists or tables, proactively incorporate index calculations before removing items to manage focus restoration correctly.] -## $(date +%Y-%m-%d) - Add Confirmation Dialog for CSV Import +## 2026-07-28 - Add Confirmation Dialog for CSV Import **Learning:** File import actions that completely overwrite existing application state can lead to severe data loss if triggered accidentally. In a WBS planner where users invest significant time building task hierarchies, destructive imports need explicit user confirmation. **Action:** Always add a confirmation dialog (`window.confirm` or custom modal) for any import or sync action that wipes out the current in-memory or persisted state, especially when there's no undo mechanism. -## $(date +%Y-%m-%d) - Prevent accidental data loss in inline editors +## 2026-07-28 - Prevent accidental data loss in inline editors **Learning:** Forms that take a long time to fill out (like a WBS editor) are prone to accidental closure by users pressing `Escape` or clicking cancel. This causes immediate data loss without any warning, resulting in frustration. **Action:** When working on editors that can be dismissed, track whether the user has modified any fields compared to their initial state. If there are changes, intercept the close action and present a confirmation dialog (`window.confirm`) to ensure they really want to discard their edits. Bypass this for intentional saves or explicit data overrides. + +## 2026-07-28 - Replace disabled with aria-disabled on submit buttons +**Learning:** Native `disabled` attributes on submit buttons prevent focus and block clicks for keyboard and screen-reader users, making the reason for the disabled state inaccessible. However, replacing it with `aria-disabled="true"` will cause the button to trigger native form submission. +**Action:** When replacing `disabled` with `aria-disabled="true"` on a submit button, ensure the form's `submit` event listener explicitly checks for the `aria-disabled` attribute and calls `event.preventDefault()` to block unintended execution. diff --git a/AGENTS.md b/AGENTS.md index 7a5b65ac..dac2bebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ ## Verification - Serve locally with `python3 -m http.server 4173`. -- Run end-to-end verification with `npm run test:e2e`. +- Run end-to-end verification with `pnpm run test:e2e`. - Run workflow ownership checks with `python3 -m pytest tests/config`. ## CI / security workflow notes diff --git a/CLAUDE.md b/CLAUDE.md index b3596e61..5c0ed2c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,14 +26,14 @@ python3 -m http.server 4173 # open http://127.0.0.1:4173 # Cloud server (Node >= 22 — uses node:sqlite) npm install -npm run server # API + static client on :8787 +pnpm run server # API + static client on :8787 # Tests -npm run test:unit # pure-math: EVM/S-curve, CPM, baselines, workload, … -npm run test:api # API smoke (auth·tenancy·RBAC·billing·webhooks) + rate limit -npm run test:e2e # Playwright UI suite (auto-starts http.server on :4173) -npm run test:e2e:cloud # cloud UI spec only -npm run fuzz # fast-check property fuzz (node --test tests/fuzz/*.mjs) +pnpm run test:unit # pure-math: EVM/S-curve, CPM, baselines, workload, … +pnpm run test:api # API smoke (auth·tenancy·RBAC·billing·webhooks) + rate limit +pnpm run test:e2e # Playwright UI suite (auto-starts http.server on :4173) +pnpm run test:e2e:cloud # cloud UI spec only +pnpm run fuzz # fast-check property fuzz (node --test tests/fuzz/*.mjs) python3 -m pytest tests/config # workflow-ownership / governance checks # Single test: unit tests are plain Node scripts diff --git a/README.md b/README.md index 3011a929..599d6ea0 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Cloud (Node ≥ 22): ```bash npm install -npm run server # serves the API + the static client on :8787 +pnpm run server # serves the API + the static client on :8787 ``` Docker: `docker compose up` (see `Dockerfile.server` / `docs/deploy.md`). @@ -103,9 +103,9 @@ Docker: `docker compose up` (see `Dockerfile.server` / `docs/deploy.md`). ## Verification ```bash -npm run test:api # API smoke (auth, tenancy, RBAC, billing, webhooks, …) + rate limit -npm run test:unit # EVM/S-curve, CPM, baseline-compare (pure math) -npm run test:e2e # Playwright UI suite +pnpm run test:api # API smoke (auth, tenancy, RBAC, billing, webhooks, …) + rate limit +pnpm run test:unit # EVM/S-curve, CPM, baseline-compare (pure math) +pnpm run test:e2e # Playwright UI suite python3 -m pytest tests/config ``` diff --git a/app.js b/app.js index b8c62279..541025df 100644 --- a/app.js +++ b/app.js @@ -408,6 +408,13 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { return; } event.preventDefault(); + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn && submitBtn.getAttribute('aria-disabled') === 'true') { + showToast(submitBtn.title || '현재 사용할 수 없는 기능입니다.'); + return; + } + renderDraftValidation.flush(); saveEditor(); }); @@ -1047,7 +1054,11 @@ function renderEditorValidation() { const saveButton = form.querySelector('button[type="submit"]'); if (saveButton) { - saveButton.disabled = errors.length > 0; + if (errors.length > 0) { + saveButton.setAttribute('aria-disabled', 'true'); + } else { + saveButton.removeAttribute('aria-disabled'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/cloud-sync.js b/cloud-sync.js index 7e44932b..48468706 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -740,8 +740,14 @@ function openReportModal() { // hand-edited files ever matter. export function parseMsProjectXml(xml) { const tag = (block, name) => { - const m = block.match(new RegExp(`<${name}>([^<]*)`)); - return m ? m[1].trim() : ''; + const startTag = `<${name}>`; + const endTag = ``; + const startIdx = block.indexOf(startTag); + if (startIdx === -1) return ''; + const valStart = startIdx + startTag.length; + const endIdx = block.indexOf(endTag, valStart); + if (endIdx === -1) return ''; + return block.substring(valStart, endIdx).trim(); }; const unescape = (s) => s .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') diff --git a/package.json b/package.json index 9ae8b292..4b82b443 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "Production-grade pure HTML/CSS/JS WBS planner", "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage && npm run test:fuzz", + "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage && pnpm run test:fuzz", "server": "node server/server.mjs", "test:api": "node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", "test:unit": "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/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.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", @@ -17,11 +17,12 @@ "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { - "@hono/node-server": "^1.19.14", - "hono": "^4.12.27" + "@hono/node-server": "^2.0.12", + "hono": "^4.12.32" }, "devDependencies": { "@playwright/test": "1.61.1", "fast-check": "4.9.0" - } + }, + "packageManager": "pnpm@10.30.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bffabf92..1868650a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@hono/node-server': - specifier: ^1.19.14 - version: 1.19.14(hono@4.12.28) + specifier: ^2.0.12 + version: 2.0.12(hono@4.12.32) hono: - specifier: ^4.12.27 - version: 4.12.28 + specifier: ^4.12.32 + version: 4.12.32 devDependencies: '@playwright/test': specifier: 1.61.1 @@ -24,9 +24,9 @@ importers: packages: - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -44,8 +44,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - hono@4.12.28: - resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} + hono@4.12.32: + resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} engines: {node: '>=16.9.0'} playwright-core@1.61.1: @@ -63,9 +63,9 @@ packages: snapshots: - '@hono/node-server@1.19.14(hono@4.12.28)': + '@hono/node-server@2.0.12(hono@4.12.32)': dependencies: - hono: 4.12.28 + hono: 4.12.32 '@playwright/test@1.61.1': dependencies: @@ -78,7 +78,7 @@ snapshots: fsevents@2.3.2: optional: true - hono@4.12.28: {} + hono@4.12.32: {} playwright-core@1.61.1: {} diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 96c69057..468549de 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1333,4 +1333,53 @@ test.describe('ScopeWeave Planner - Palette UX Enhancements', () => { await expect(backBtn).toHaveAttribute('title', '작업 목록으로 돌아가기 (Esc)'); await expect(backBtn).toHaveAttribute('aria-keyshortcuts', 'Escape'); }); + test('ScopeWeave Planner - Palette UX Enhancements - blocks form submission when save button is aria-disabled', async ({ page }) => { + // Inject seed data to edit + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'ScopeWeave Planner', + baseDate: '2026-07-10', + tasks: [{ + id: 'task-1', + phase: 'P1', + depth: 1, + expanded: true + }] + })); + }); + await page.reload(); + + // Open editor for the task + await page.locator('tr[data-task-id="task-1"] [data-action="edit"]').click(); + + // Clear the required phase field to trigger validation errors + const phaseInput = page.locator('[data-testid="editor-phase"]'); + await phaseInput.fill(''); + + // Wait for the save button to become aria-disabled + const saveBtn = page.locator('.editor-actions button[type="submit"]'); + await expect(saveBtn).toHaveAttribute('aria-disabled', 'true'); + + // Ensure form is visible + const form = page.locator('form[data-editor-form="true"]'); + await expect(form).toBeVisible(); + + // Click the submit button + await saveBtn.click(); + + // Check that toast shows the error message + const toast = page.locator('#toast'); + await expect(toast).toContainText('입력값을 올바르게 수정해야 저장할 수 있습니다.'); + + // Form should still be visible because submission was prevented + await expect(form).toBeVisible(); + + // Fill it back and save to ensure normal submission works + await phaseInput.fill('Valid Phase'); + await expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'); + await saveBtn.click(); + + // Editor should close + await expect(form).not.toBeVisible(); + }); });