diff --git a/app.js b/app.js index a04aae71..bacbb51f 100644 --- a/app.js +++ b/app.js @@ -87,7 +87,7 @@ const CSV_HEADERS = [ '스프린트', '스토리포인트' ]; -const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/; +const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|=+-@|]/; const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const CSV_FIELD_LABELS = Object.freeze(Object.assign(Object.create(null), { @@ -790,6 +790,9 @@ function renderEditorRow(anchorId) { panel.className = 'editor-panel'; const form = document.createElement('form'); form.dataset.editorForm = 'true'; + // ScopeWeave validation is the sole persistence gate; native constraint + // validation would intercept submission and move focus before saveEditor(). + form.noValidate = true; const editorGrid = document.createElement('div'); editorGrid.className = 'editor-grid'; @@ -1068,7 +1071,13 @@ 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'); + saveButton.setAttribute('aria-describedby', 'editor-errors'); + } else { + saveButton.removeAttribute('aria-disabled'); + saveButton.removeAttribute('aria-describedby'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/package.json b/package.json index 8cefdc74..4b9c6d14 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "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: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", + "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js tests/e2e/editor-validation-synchronization.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..d1ffd7bc 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; import fc from 'fast-check'; +const DANGEROUS_CSV_PREFIX_PATTERN = /^\s*[=+\-@|=+-@|]/; + test.describe('CSV formula fuzzing', () => { test.beforeEach(async ({ page }) => { await page.goto('./'); @@ -14,16 +16,35 @@ test.describe('CSV formula fuzzing', () => { sanitized: window.sanitizeCsvFormulaValue(value) }), candidate); const normalized = String(candidate ?? ''); - const dangerous = /^\s*[=+\-@|]/.test(normalized); + const dangerous = DANGEROUS_CSV_PREFIX_PATTERN.test(normalized); const expectedSanitized = dangerous ? `'${normalized}` : normalized; expect(result.sanitized).toBe(expectedSanitized); expect(result.escaped.startsWith('"')).toBe(true); expect(result.escaped.endsWith('"')).toBe(true); expect(result.escaped.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); - expect(/^\s*[=+\-@|]/.test(result.sanitized)).toBe(false); + expect(DANGEROUS_CSV_PREFIX_PATTERN.test(result.sanitized)).toBe(false); }), { numRuns: 100, seed: 20260709 } ); }); -}); + + test('neutralizes fullwidth formula-prefix compatibility characters', async ({ page }) => { + const compatibilityPrefixes = ['=', '+', '-', '@', '|']; + + for (const prefix of compatibilityPrefixes) { + for (const candidate of [`${prefix}1+1`, ` \t${prefix}SUM(A1:A2)`]) { + const result = await page.evaluate((value) => ({ + escaped: window.csvEscape(value), + sanitized: window.sanitizeCsvFormulaValue(value) + }), candidate); + const expectedSanitized = `'${candidate}`; + + expect(result.sanitized).toBe(expectedSanitized); + expect(result.escaped.startsWith('"')).toBe(true); + expect(result.escaped.endsWith('"')).toBe(true); + expect(result.escaped.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); + } + } + }); +}); \ No newline at end of file diff --git a/tests/e2e/editor-validation-synchronization.spec.js b/tests/e2e/editor-validation-synchronization.spec.js new file mode 100644 index 00000000..cff9b985 --- /dev/null +++ b/tests/e2e/editor-validation-synchronization.spec.js @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test'; + +async function openRootEditor(page) { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + const editor = page.locator('.editor-panel'); + const phaseInput = page.getByTestId('editor-phase'); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + await expect(editor).toBeVisible(); + return { initialTaskCount, editor, phaseInput, saveButton }; +} + +test('valid final edit can submit immediately without waiting for debounced validation', async ({ page }) => { + const { initialTaskCount, editor, phaseInput, saveButton } = await openRootEditor(page); + + await expect(saveButton).toHaveJSProperty('disabled', false); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + + await phaseInput.fill('P9000.즉시 저장 검증'); + await saveButton.evaluate((button) => button.click()); + + await expect(editor).toHaveCount(0); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 1); +}); + +test('Enter after the final required edit submits against the latest draft', async ({ page }) => { + const { initialTaskCount, editor, phaseInput } = await openRootEditor(page); + + await phaseInput.fill('P9001.키보드 즉시 저장'); + await phaseInput.press('Enter'); + + await expect(editor).toHaveCount(0); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 1); +}); + +test('invalid immediate activation stays focusable, refreshes errors, and persists nothing', async ({ page }) => { + const { initialTaskCount, editor, phaseInput, saveButton } = await openRootEditor(page); + + await phaseInput.fill('P9002.유효 상태'); + await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).not.toHaveAttribute('aria-describedby', 'editor-errors'); + + await phaseInput.fill(''); + await saveButton.evaluate((button) => { + button.focus(); + button.click(); + }); + + await expect(editor).toBeVisible(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); + await expect(saveButton).toHaveJSProperty('disabled', false); + await expect(saveButton).toBeFocused(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); +});