Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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');
}
Comment on lines +1074 to +1080

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Invalid save now blocked only by synchronous save-time validation

Switching the save button from native disabled to aria-disabled (app.js:1074-1080) leaves it always operable. Invalid submits are now gated solely by the synchronous re-validation in saveEditor() (app.js:1245-1250), reached after renderDraftValidation.flush() in the submit handler (app.js:426-434). The debounced validation only drives presentation, so the stale aria-disabled window between a keystroke and the 150ms debounce neither rejects a valid submit nor admits an invalid one.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)';
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 24 additions & 3 deletions tests/e2e/csv_formula_fuzz.spec.js
Original file line number Diff line number Diff line change
@@ -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('./');
Expand All @@ -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);
}
}
});
});
58 changes: 58 additions & 0 deletions tests/e2e/editor-validation-synchronization.spec.js
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
seonghobae marked this conversation as resolved.

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('최상위 작업은 단계 값을 입력해야 합니다.');
});
Loading