From 1d9cd037ab590d87fc511b819bce89bbdef1b581 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:00:08 +0900 Subject: [PATCH 01/10] fix(a11y): synchronize editor validation and submit --- CHANGELOG.md | 4 + app.js | 8 +- .../editor-save-validation-accessibility.md | 76 +++++++++++++++++++ index.html | 2 + package.json | 2 +- tests/e2e/editor-validation-sync.spec.js | 69 +++++++++++++++++ tests/e2e/scopeweave.spec.js | 36 ++++----- 7 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 docs/doctoring/editor-save-validation-accessibility.md create mode 100644 tests/e2e/editor-validation-sync.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 787ee51b..610ae188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Kept invalid editor save actions keyboard-discoverable with + `aria-disabled="true"` and an explicit `aria-describedby` relationship while + preserving synchronous submit-time validation as the only persistence gate; + immediately corrected click and Enter submissions now use the latest draft. - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with per-item abortable timeouts and a request-wide latency budget, preserves stale status after downstream, diff --git a/app.js b/app.js index a04aae71..e25a8e90 100644 --- a/app.js +++ b/app.js @@ -1068,7 +1068,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/docs/doctoring/editor-save-validation-accessibility.md b/docs/doctoring/editor-save-validation-accessibility.md new file mode 100644 index 00000000..2dc9100f --- /dev/null +++ b/docs/doctoring/editor-save-validation-accessibility.md @@ -0,0 +1,76 @@ +# Focusable editor validation and synchronous save authority + +## Decision + +The editor save button remains a native button in the sequential keyboard order. +When the current draft is invalid, ScopeWeave exposes +`aria-disabled="true"`, connects the button to `#editor-errors` with +`aria-describedby`, and keeps the control physically focusable. Activation is +still accepted as an input event, but `saveEditor()` synchronously validates the +latest draft and refuses persistence while errors remain. + +The debounced validation pass is presentation only. It updates field error +states, the error summary, and save-button semantics; it is not an authorization +or persistence boundary. This avoids two inverse races: + +- a user corrects the final error and immediately clicks or presses Enter before + the debounce updates a stale disabled state; and +- a user introduces an error and immediately submits before the presentation + layer catches up. + +Both paths are decided by the same latest-draft validation inside +`saveEditor()`. + +## Accessibility rationale + +WAI-ARIA defines `aria-disabled` as a perceivable disabled state. W3C's +Authoring Practices notes that disabled commands can remain focusable when their +discoverability is useful, provided scripting prevents the unavailable action. +The save action is a primary command whose error relationship benefits from +keyboard discovery, so ScopeWeave keeps it focusable and exposes the current +error summary as its accessible description. + +The native `disabled` attribute is not used for this state because it removes the +button from normal keyboard focus and can preserve a stale block while the +debounced presentation state catches up. The implementation must not treat +`aria-disabled` alone as enforcement; synchronous validation prevents mutation. + +## Executable evidence + +`tests/e2e/editor-validation-sync.spec.js` verifies: + +- an invalid save control remains focusable and described; +- activating it does not create a task; +- a draft corrected immediately before click saves without waiting for debounce; +- a draft corrected immediately before Enter saves without waiting for debounce; +- a newly invalid draft cannot persist before debounce completes; and +- error text remains available through `#editor-errors`. + +The existing full-browser suite is updated to activate invalid save controls and +assert that task count and editor state are unchanged for reversed dates, +invalid calendar dates, and HTML input. It also re-enables the complete +`scopeweave.spec.js` cloud path and restores module-preload assertions for the +three production modules. + +## Compatibility and rollback + +This change does not modify persisted WBS data, API contracts, authentication, +or server storage. It changes only the editor's presentation semantics and keeps +existing synchronous validation behavior as the persistence authority. + +Rollback must revert the button-state implementation, focused browser tests, +full-suite expectations, module-preload restoration, package script, CHANGELOG +entry, and this record together. Reintroducing native `disabled` requires a new +proof that immediate correction cannot be blocked by stale debounced state. + +## References + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications +(WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (2025). *Developing a keyboard interface*. +WAI-ARIA Authoring Practices Guide. +https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/index.html b/index.html index a7f4b49c..cda50f78 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + diff --git a/package.json b/package.json index 46d07bfb..80a66cec 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/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", + "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js tests/e2e/editor-validation-sync.spec.js tests/e2e/scopeweave.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/editor-validation-sync.spec.js b/tests/e2e/editor-validation-sync.spec.js new file mode 100644 index 00000000..571f1c73 --- /dev/null +++ b/tests/e2e/editor-validation-sync.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from '@playwright/test'; + +async function openRootEditor(page) { + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + return page.locator('.editor-panel'); +} + +test('invalid save stays focusable and programmatically described', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + + await expect(saveButton).not.toHaveAttribute('disabled'); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.evaluate((button) => button.click()); + + await expect(editor).toBeVisible(); + // Native form validation may move focus to the first invalid required field. + await expect(page.locator('#editor-errors')).not.toHaveText(''); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); +}); + +test('latest valid draft saves immediately by click and Enter', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + + let editor = await openRootEditor(page); + let phaseInput = page.getByTestId('editor-phase'); + let saveButton = editor.getByRole('button', { name: '저장', exact: true }); + 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); + + editor = await openRootEditor(page); + phaseInput = page.getByTestId('editor-phase'); + await phaseInput.fill('P9001.즉시 엔터'); + await phaseInput.press('Enter'); + + await expect(editor).toHaveCount(0); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 2); +}); + +test('latest invalid draft cannot save before debounce completes', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const phaseInput = page.getByTestId('editor-phase'); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + + await phaseInput.fill('valid'); + await page.waitForTimeout(200); + await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + + await phaseInput.fill(''); + await saveButton.evaluate((button) => button.click()); + + await expect(editor).toBeVisible(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + await expect(page.locator('#editor-errors')).not.toHaveText(''); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); +}); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 96c69057..8b75397c 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -14,8 +14,16 @@ const addTopLevelTask = async (page, values) => { const expectSaveBlockedWith = async (page, message) => { const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + + await expect(saveButton).not.toHaveAttribute('disabled'); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); await expect(page.locator('#editor-errors')).toContainText(message); + + await saveButton.evaluate((button) => button.click()); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); await expect(page.locator('.editor-panel')).toBeVisible(); }; @@ -702,12 +710,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-05-20'); await page.locator('[data-testid="editor-planned-end"]').fill('2026-05-19'); - const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, '계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('[data-testid="editor-planned-end"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); - await expect(page.locator('.editor-panel')).toBeVisible(); }); test('rejects invalid calendar dates in the editor', async ({ page }) => { @@ -719,12 +724,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-02-31'); - const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, '계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('[data-testid="editor-planned-start"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); - await expect(page.locator('.editor-panel')).toBeVisible(); }); test('rejects HTML payloads from the UI editor', async ({ page }) => { @@ -732,11 +734,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-task"]').fill(''); - const saveButton = page.locator('.editor-panel').getByRole('button', { name: '저장' }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, 'HTML 태그 문자를 사용할 수 없습니다'); await expect(page.locator('[data-testid="editor-task"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('HTML 태그 문자를 사용할 수 없습니다'); - await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.locator('#task-table-body')).not.toContainText(''); }); test('validateDraft pure function logic', async ({ page }) => { @@ -1092,13 +1092,13 @@ test.describe('ScopeWeave Planner', () => { test('renders empty cells as independent DOM clones', async ({ page }) => { const result = await page.evaluate(() => { - const emptyCells = Array.from(document.querySelectorAll('.empty-cell')); + const emptyCells = [ + window.createTextCellContent(''), + window.createTextCellContent('') + ]; + document.body.append(...emptyCells); const [first, second] = emptyCells; - if (!first || !second) { - return { count: emptyCells.length, uniqueCount: new Set(emptyCells).size }; - } - first.querySelector('[aria-hidden="true"]').textContent = 'x'; first.querySelector('.sr-only').textContent = 'mutated'; From cfed01fec7f5c3d176dba3739748b93108d5bf7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:27:36 +0900 Subject: [PATCH 02/10] test(a11y): cover editor validation semantics in c8 --- tests/unit/editor-unsaved.test.mjs | 51 +++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/unit/editor-unsaved.test.mjs b/tests/unit/editor-unsaved.test.mjs index 2fb5bd16..b839c892 100644 --- a/tests/unit/editor-unsaved.test.mjs +++ b/tests/unit/editor-unsaved.test.mjs @@ -18,6 +18,7 @@ function loadApp() { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + renderEditorValidation, state, DEFAULT_EDITOR_STATE, }; @@ -116,17 +117,27 @@ function loadApp() { if (!exportsObj?.editorHasUnsavedChanges) { throw new Error('Failed to extract editor exports from app.js'); } - return { ...exportsObj, windowListeners, setConfirm: (fn) => { confirmImpl = fn; } }; + return { + ...exportsObj, + windowListeners, + setConfirm: (fn) => { confirmImpl = fn; }, + setEditorValidationDom: ({ errorElement, form }) => { + sandbox.document.getElementById = (id) => (id === 'editor-errors' ? errorElement : null); + sandbox.document.querySelector = (selector) => (selector === 'form[data-editor-form="true"]' ? form : null); + }, + }; } const { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + renderEditorValidation, state, DEFAULT_EDITOR_STATE, windowListeners, setConfirm, + setEditorValidationDom, } = loadApp(); // --- editorHasUnsavedChanges --- @@ -228,4 +239,42 @@ setConfirm(() => { closeEditor(true); assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'force close skips confirm'); +// --- renderEditorValidation accessibility state --- +const saveAttributes = new Map(); +const saveButton = { + title: '', + setAttribute(name, value) { + saveAttributes.set(name, value); + }, + removeAttribute(name) { + saveAttributes.delete(name); + }, +}; +const errorElement = { textContent: '' }; +const editorForm = { + querySelector(selector) { + assert.equal(selector, 'button[type="submit"]'); + return saveButton; + }, + querySelectorAll(selector) { + assert.equal(selector, 'input[data-editor-field]'); + return []; + }, +}; +setEditorValidationDom({ errorElement, form: editorForm }); + +state.editor = { mode: 'create', depth: 1, draft: { phase: '' }, errors: [] }; +renderEditorValidation(); +assert.equal(saveAttributes.get('aria-disabled'), 'true', 'invalid draft exposes disabled semantics'); +assert.equal(saveAttributes.get('aria-describedby'), 'editor-errors', 'invalid save references the error summary'); +assert.match(errorElement.textContent, /단계 값을 입력해야 합니다/, 'invalid draft publishes its error summary'); +assert.equal(saveButton.title, '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + +state.editor = { mode: 'create', depth: 1, draft: { phase: 'P1000.검증' }, errors: [] }; +renderEditorValidation(); +assert.equal(saveAttributes.has('aria-disabled'), false, 'valid draft removes disabled semantics'); +assert.equal(saveAttributes.has('aria-describedby'), false, 'valid save removes stale error relationship'); +assert.equal(errorElement.textContent, '', 'valid draft clears the error summary'); +assert.equal(saveButton.title, '저장 (Enter)'); + console.log('✓ editor unsaved / beforeunload coverage tests passed'); From 2283a84893b67748144f39086306d48d46f5bf32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:32:34 +0900 Subject: [PATCH 03/10] test(security): cover fullwidth CSV formula prefixes --- tests/e2e/csv_formula_fuzz.spec.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..b7012699 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -26,4 +26,21 @@ test.describe('CSV formula fuzzing', () => { { 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.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); + } + } + }); +}); \ No newline at end of file From afc93afd73be2b6e0d98b3783490afab2a4b28a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:39:22 +0900 Subject: [PATCH 04/10] fix(security): neutralize fullwidth CSV formula prefixes --- app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.js b/app.js index e25a8e90..91ee52d1 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), { From 7daf5c101b7e3afc40a7298ca73e84faa97bb2ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:15:29 +0900 Subject: [PATCH 05/10] test(security): assert fullwidth CSV quote boundaries --- tests/e2e/csv_formula_fuzz.spec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index b7012699..3d97a0b4 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -39,6 +39,8 @@ test.describe('CSV formula fuzzing', () => { 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); } } From d4228f5e0de5eadb32b3cfb72b1235d74af842ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:30:38 -0700 Subject: [PATCH 06/10] chore(scope): keep module preload in dedicated PR --- docs/doctoring/editor-save-validation-accessibility.md | 9 ++++----- index.html | 2 -- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/editor-save-validation-accessibility.md b/docs/doctoring/editor-save-validation-accessibility.md index 2dc9100f..e65fbb8f 100644 --- a/docs/doctoring/editor-save-validation-accessibility.md +++ b/docs/doctoring/editor-save-validation-accessibility.md @@ -49,8 +49,7 @@ debounced presentation state catches up. The implementation must not treat The existing full-browser suite is updated to activate invalid save controls and assert that task count and editor state are unchanged for reversed dates, invalid calendar dates, and HTML input. It also re-enables the complete -`scopeweave.spec.js` cloud path and restores module-preload assertions for the -three production modules. +`scopeweave.spec.js` cloud path for the editor acceptance boundary. ## Compatibility and rollback @@ -59,9 +58,9 @@ or server storage. It changes only the editor's presentation semantics and keeps existing synchronous validation behavior as the persistence authority. Rollback must revert the button-state implementation, focused browser tests, -full-suite expectations, module-preload restoration, package script, CHANGELOG -entry, and this record together. Reintroducing native `disabled` requires a new -proof that immediate correction cannot be blocked by stale debounced state. +full-suite expectations, package script, CHANGELOG entry, and this record +together. Reintroducing native `disabled` requires a new proof that immediate +correction cannot be blocked by stale debounced state. ## References diff --git a/index.html b/index.html index 8c1a832f..12e33306 100644 --- a/index.html +++ b/index.html @@ -6,8 +6,6 @@ ScopeWeave Planner - - From 588bdaa980cbb994d1448c93f3f0e4fd5623c57e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:38:47 -0700 Subject: [PATCH 07/10] fix(test): preserve preload contract for full browser suite --- index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/index.html b/index.html index 12e33306..8c1a832f 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + From f1350f9812a65a228e72fc81750cd3749496038c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:39:31 -0700 Subject: [PATCH 08/10] test(security): align CSV fuzz oracle with fullwidth prefixes --- tests/e2e/csv_formula_fuzz.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 3d97a0b4..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,14 +16,14 @@ 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 } ); From fca25c7700441acbf1598d26e4eff328c3195ac1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:07:30 -0700 Subject: [PATCH 09/10] test(a11y): require invalid save focus preservation --- tests/e2e/editor-validation-sync.spec.js | 62 ++++++++++++++++-------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/tests/e2e/editor-validation-sync.spec.js b/tests/e2e/editor-validation-sync.spec.js index 571f1c73..8f77436a 100644 --- a/tests/e2e/editor-validation-sync.spec.js +++ b/tests/e2e/editor-validation-sync.spec.js @@ -5,24 +5,51 @@ async function openRootEditor(page) { return page.locator('.editor-panel'); } -test('invalid save stays focusable and programmatically described', async ({ page }) => { +async function makeSavePresentationValid(page, saveButton) { + const phaseInput = page.getByTestId('editor-phase'); + await phaseInput.fill('temporarily valid'); + await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).not.toHaveAttribute('aria-describedby', 'editor-errors'); + return phaseInput; +} + +async function expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount) { + await expect(saveButton).toBeFocused(); + await expect(editor).toBeVisible(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); +} + +test('invalid pointer save refreshes validation and preserves save focus', async ({ page }) => { await page.goto('./'); const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); const editor = await openRootEditor(page); const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + const phaseInput = await makeSavePresentationValid(page, saveButton); - await expect(saveButton).not.toHaveAttribute('disabled'); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); - await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + await phaseInput.fill(''); + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.click(); + + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); +}); +test('invalid keyboard save refreshes validation and preserves save focus', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + const phaseInput = await makeSavePresentationValid(page, saveButton); + + await phaseInput.fill(''); await saveButton.focus(); await expect(saveButton).toBeFocused(); - await saveButton.evaluate((button) => button.click()); + await saveButton.press('Enter'); - await expect(editor).toBeVisible(); - // Native form validation may move focus to the first invalid required field. - await expect(page.locator('#editor-errors')).not.toHaveText(''); - await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); }); test('latest valid draft saves immediately by click and Enter', async ({ page }) => { @@ -33,7 +60,7 @@ test('latest valid draft saves immediately by click and Enter', async ({ page }) let phaseInput = page.getByTestId('editor-phase'); let saveButton = editor.getByRole('button', { name: '저장', exact: true }); await phaseInput.fill('P9000.즉시 클릭'); - await saveButton.evaluate((button) => button.click()); + await saveButton.click(); await expect(editor).toHaveCount(0); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 1); @@ -51,19 +78,12 @@ test('latest invalid draft cannot save before debounce completes', async ({ page await page.goto('./'); const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); const editor = await openRootEditor(page); - const phaseInput = page.getByTestId('editor-phase'); const saveButton = editor.getByRole('button', { name: '저장', exact: true }); - - await phaseInput.fill('valid'); - await page.waitForTimeout(200); - await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + const phaseInput = await makeSavePresentationValid(page, saveButton); await phaseInput.fill(''); - await saveButton.evaluate((button) => button.click()); + await saveButton.focus(); + await saveButton.click(); - await expect(editor).toBeVisible(); - await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); - await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); - await expect(page.locator('#editor-errors')).not.toHaveText(''); - await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); }); From 815af8138df2454cea99ba1a4a384de4f55d6199 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:17:32 -0700 Subject: [PATCH 10/10] fix(a11y): keep editor validation in the submit path --- app.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app.js b/app.js index 91ee52d1..bacbb51f 100644 --- a/app.js +++ b/app.js @@ -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';