From 46d3ce870c902325cb77e8cce521a87589194d4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:03:23 -0700 Subject: [PATCH 01/32] test: reproduce numeric zero loss through editor round trip --- tests/e2e/numeric-zero-roundtrip.spec.js | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/e2e/numeric-zero-roundtrip.spec.js diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js new file mode 100644 index 00000000..38be789c --- /dev/null +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +const createTask = (overrides = {}) => ({ + id: 'task-zero-regression', + parentId: null, + depth: 1, + expanded: true, + pendingDelete: false, + isSynthetic: false, + phase: 'P0000.제로값 회귀', + activity: '', + task: '', + categoryLarge: '데이터 무결성', + categoryMedium: '', + documentName: '', + owner: 'QA', + supportTeam: '', + plannedStartDate: '2026-08-25', + plannedEndDate: '2026-08-26', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: '', + budget: '', + actualCost: '', + sprint: '', + storyPoints: '', + ...overrides, +}); + +const seedPersistedTask = async (page, overrides) => { + await page.addInitScript(({ storageKey, task }) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'Numeric Zero Integrity', + baseDate: '2026-08-26', + tasks: [task], + })); + }, { storageKey: STORAGE_KEY, task: createTask(overrides) }); + await page.goto('./'); +}; + +const openEditor = async (page) => { + const row = page.locator('tbody tr[data-task-id]').first(); + await expect(row).toHaveCount(1); + await row.getByRole('button', { name: /^편집/ }).click(); +}; + +const saveAndReopen = async (page) => { + await page.getByRole('button', { name: '저장', exact: true }).click(); + await openEditor(page); +}; + +for (const { field, testId } of [ + { field: 'budget', testId: 'editor-budget' }, + { field: 'actualCost', testId: 'editor-actual-cost' }, + { field: 'storyPoints', testId: 'editor-story-points' }, +]) { + test(`preserves numeric zero for ${field} through edit/save/reopen`, async ({ page }) => { + await seedPersistedTask(page, { [field]: 0 }); + await openEditor(page); + + await expect(page.getByTestId(testId)).toHaveValue('0'); + await saveAndReopen(page); + await expect(page.getByTestId(testId)).toHaveValue('0'); + + const persistedValue = await page.evaluate(({ storageKey, fieldName }) => { + const saved = JSON.parse(localStorage.getItem(storageKey)); + return saved.tasks[0][fieldName]; + }, { storageKey: STORAGE_KEY, fieldName: field }); + expect(String(persistedValue)).toBe('0'); + }); +} + +test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { + await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); + + await saveAndReopen(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); +}); From 005918cc1c5d126ca6a9fbe3b8597e34b53e2aec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:44:36 -0700 Subject: [PATCH 02/32] test: run numeric zero regression in cloud e2e --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8cefdc74..6326e7af 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/numeric-zero-roundtrip.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, From a9e8c863210888db3237c2c735db40ee0a3b3083 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:46:07 -0700 Subject: [PATCH 03/32] test: cover zero normalization and CSV export --- tests/e2e/numeric-zero-roundtrip.spec.js | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 38be789c..c192e7e3 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -41,6 +41,17 @@ const seedPersistedTask = async (page, overrides) => { await page.goto('./'); }; +const seedExternalTask = async (page, overrides) => { + await page.route('**/wbs.json', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([createTask(overrides)]), + }); + }); + await page.goto('./'); +}; + const openEditor = async (page) => { const row = page.locator('tbody tr[data-task-id]').first(); await expect(row).toHaveCount(1); @@ -52,6 +63,15 @@ const saveAndReopen = async (page) => { await openEditor(page); }; +const readDownloadText = async (download) => { + const stream = await download.createReadStream(); + let content = ''; + for await (const chunk of stream) { + content += chunk.toString('utf8'); + } + return content; +}; + for (const { field, testId } of [ { field: 'budget', testId: 'editor-budget' }, { field: 'actualCost', testId: 'editor-actual-cost' }, @@ -87,3 +107,24 @@ test('preserves budget, actual cost, and story points when all are numeric zero' await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); }); + +test('preserves numeric zero while normalizing an external wbs.json record', async ({ page }) => { + await seedExternalTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); +}); + +test('exports numeric zero values instead of empty CSV cells', async ({ page }) => { + await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const csvText = await readDownloadText(await downloadPromise); + const [, dataRow] = csvText.trimEnd().split(/\r?\n/); + const cells = dataRow.split(',').map((cell) => cell.slice(1, -1).replace(/""/g, '"')); + + expect(cells.slice(-4)).toEqual(['0', '0', '', '0']); +}); From 41a38d68087f8498fafc6f28df63def9a9619c15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 23:18:08 -0700 Subject: [PATCH 04/32] fix: preserve numeric zero values across normalization --- app.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app.js b/app.js index a04aae71..6c81588e 100644 --- a/app.js +++ b/app.js @@ -870,7 +870,7 @@ function renderEditorField(label, field, value, type = 'text', required = false, if (type === 'text') { input.maxLength = 1000; } - input.value = value || ''; + input.value = value ?? ''; if (required) { input.required = true; input.setAttribute('aria-required', 'true'); @@ -1310,7 +1310,7 @@ function sanitizeDraft(draft) { const sanitized = {}; EDITABLE_FIELDS.forEach((field) => { // 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion - sanitized[field] = String(draft?.[field] || '').trim().slice(0, 1000); + sanitized[field] = String(draft?.[field] ?? '').trim().slice(0, 1000); }); // 🛡️ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { @@ -1813,10 +1813,10 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({ actualStartDate: task.actualStartDate || '', actualEndDate: task.actualEndDate || '', predecessors: task.predecessors || defaults.predecessors || '', - budget: task.budget || defaults.budget || '', - actualCost: task.actualCost || defaults.actualCost || '', + budget: task.budget ?? defaults.budget ?? '', + actualCost: task.actualCost ?? defaults.actualCost ?? '', sprint: task.sprint || defaults.sprint || '', - storyPoints: task.storyPoints || defaults.storyPoints || '' + storyPoints: task.storyPoints ?? defaults.storyPoints ?? '' }); function getPhaseKey(task, index) { @@ -1962,10 +1962,10 @@ function exportCsv() { task.parentId || '', task.depth, task.predecessors || '', - task.budget || '', - task.actualCost || '', + task.budget ?? '', + task.actualCost ?? '', task.sprint || '', - task.storyPoints || '' + task.storyPoints ?? '' ]; }); From bb2a2256b1860cfdddeab33a837006f3ed9be110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:12:31 -0700 Subject: [PATCH 05/32] test: guard unrelated falsy editor semantics --- tests/e2e/numeric-zero-roundtrip.spec.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index c192e7e3..847878c5 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -93,6 +93,22 @@ for (const { field, testId } of [ }); } +test('keeps unrelated falsy fields empty while preserving numeric zero', async ({ page }) => { + await seedPersistedTask(page, { owner: false, budget: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + await saveAndReopen(page); + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); + expect(persisted.owner).toBe(''); + expect(String(persisted.budget)).toBe('0'); +}); + test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); await openEditor(page); From 4646c41f90878215d4ef596d0056c90fcabf4a42 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:22:27 +0000 Subject: [PATCH 06/32] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- app.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 6c81588e..c7d5adf7 100644 --- a/app.js +++ b/app.js @@ -870,7 +870,7 @@ function renderEditorField(label, field, value, type = 'text', required = false, if (type === 'text') { input.maxLength = 1000; } - input.value = value ?? ''; + input.value = value === 0 ? '0' : (value || ''); if (required) { input.required = true; input.setAttribute('aria-required', 'true'); @@ -1310,7 +1310,8 @@ function sanitizeDraft(draft) { const sanitized = {}; EDITABLE_FIELDS.forEach((field) => { // 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion - sanitized[field] = String(draft?.[field] ?? '').trim().slice(0, 1000); + const val = draft?.[field]; + sanitized[field] = String(val === 0 ? '0' : (val || '')).trim().slice(0, 1000); }); // 🛡️ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { From 0b741fc206e1011ec3c9794c729c5362d069e78a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:44:55 -0700 Subject: [PATCH 07/32] test: keep non-zero falsy numeric inputs empty --- tests/e2e/numeric-zero-roundtrip.spec.js | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 847878c5..106e3c44 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -72,6 +72,11 @@ const readDownloadText = async (download) => { return content; }; +const parseFirstCsvDataRow = (csvText) => { + const [, dataRow] = csvText.trimEnd().split(/\r?\n/); + return dataRow.split(',').map((cell) => cell.slice(1, -1).replace(/""/g, '"')); +}; + for (const { field, testId } of [ { field: 'budget', testId: 'editor-budget' }, { field: 'actualCost', testId: 'editor-actual-cost' }, @@ -138,9 +143,27 @@ test('exports numeric zero values instead of empty CSV cells', async ({ page }) const downloadPromise = page.waitForEvent('download'); await page.locator('#export-csv').click(); - const csvText = await readDownloadText(await downloadPromise); - const [, dataRow] = csvText.trimEnd().split(/\r?\n/); - const cells = dataRow.split(',').map((cell) => cell.slice(1, -1).replace(/""/g, '"')); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); expect(cells.slice(-4)).toEqual(['0', '0', '', '0']); }); + +test('does not serialize boolean false from persisted numeric fields into CSV', async ({ page }) => { + await seedPersistedTask(page, { budget: false, actualCost: false, storyPoints: false }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); + + expect(cells.slice(-4)).toEqual(['', '', '', '']); +}); + +test('does not preserve boolean false from external numeric fields', async ({ page }) => { + await seedExternalTask(page, { budget: false, actualCost: false, storyPoints: false }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); + + expect(cells.slice(-4)).toEqual(['', '', '', '']); +}); From fb381e86cdb0c6bcc6397a278070cb26e964a4d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:53:36 -0700 Subject: [PATCH 08/32] fix: preserve only numeric zero in normalization --- app.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index c7d5adf7..1fec2f23 100644 --- a/app.js +++ b/app.js @@ -1814,10 +1814,10 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({ actualStartDate: task.actualStartDate || '', actualEndDate: task.actualEndDate || '', predecessors: task.predecessors || defaults.predecessors || '', - budget: task.budget ?? defaults.budget ?? '', - actualCost: task.actualCost ?? defaults.actualCost ?? '', + budget: task.budget === 0 ? 0 : (task.budget || (defaults.budget === 0 ? 0 : (defaults.budget || ''))), + actualCost: task.actualCost === 0 ? 0 : (task.actualCost || (defaults.actualCost === 0 ? 0 : (defaults.actualCost || ''))), sprint: task.sprint || defaults.sprint || '', - storyPoints: task.storyPoints ?? defaults.storyPoints ?? '' + storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || (defaults.storyPoints === 0 ? 0 : (defaults.storyPoints || ''))) }); function getPhaseKey(task, index) { @@ -1963,10 +1963,10 @@ function exportCsv() { task.parentId || '', task.depth, task.predecessors || '', - task.budget ?? '', - task.actualCost ?? '', + task.budget === 0 ? 0 : (task.budget || ''), + task.actualCost === 0 ? 0 : (task.actualCost || ''), task.sprint || '', - task.storyPoints ?? '' + task.storyPoints === 0 ? 0 : (task.storyPoints || '') ]; }); From dbb30de53889fc149957f40707d8e1f9f4be82f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:56:48 -0700 Subject: [PATCH 09/32] test: keep zero preservation numeric-field scoped --- tests/e2e/numeric-zero-roundtrip.spec.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 106e3c44..e05491ab 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -114,6 +114,22 @@ test('keeps unrelated falsy fields empty while preserving numeric zero', async ( expect(String(persisted.budget)).toBe('0'); }); +test('does not broaden numeric zero preservation to unrelated text fields', async ({ page }) => { + await seedPersistedTask(page, { owner: 0, budget: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + await saveAndReopen(page); + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); + expect(persisted.owner).toBe(''); + expect(String(persisted.budget)).toBe('0'); +}); + test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); await openEditor(page); From 7c5617e45ed29e754dbe5de5b9d189bafd016b90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:58:33 -0700 Subject: [PATCH 10/32] test: retain validated zero round-trip contract --- tests/e2e/numeric-zero-roundtrip.spec.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index e05491ab..106e3c44 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -114,22 +114,6 @@ test('keeps unrelated falsy fields empty while preserving numeric zero', async ( expect(String(persisted.budget)).toBe('0'); }); -test('does not broaden numeric zero preservation to unrelated text fields', async ({ page }) => { - await seedPersistedTask(page, { owner: 0, budget: 0 }); - await openEditor(page); - - await expect(page.getByTestId('editor-owner')).toHaveValue(''); - await expect(page.getByTestId('editor-budget')).toHaveValue('0'); - - await saveAndReopen(page); - await expect(page.getByTestId('editor-owner')).toHaveValue(''); - await expect(page.getByTestId('editor-budget')).toHaveValue('0'); - - const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); - expect(persisted.owner).toBe(''); - expect(String(persisted.budget)).toBe('0'); -}); - test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); await openEditor(page); From ea20d5d793f06364a63cb920ec03fea098508da6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:41:23 -0700 Subject: [PATCH 11/32] test: bound numeric-zero preservation to numeric fields --- tests/e2e/numeric-zero-roundtrip.spec.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 106e3c44..90de6753 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -50,6 +50,7 @@ const seedExternalTask = async (page, overrides) => { }); }); await page.goto('./'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); }; const openEditor = async (page) => { @@ -114,6 +115,22 @@ test('keeps unrelated falsy fields empty while preserving numeric zero', async ( expect(String(persisted.budget)).toBe('0'); }); +test('keeps numeric zero empty for unrelated text fields', async ({ page }) => { + await seedPersistedTask(page, { owner: 0, budget: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + await saveAndReopen(page); + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); + expect(persisted.owner).toBe(''); + expect(String(persisted.budget)).toBe('0'); +}); + test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); await openEditor(page); @@ -166,4 +183,4 @@ test('does not preserve boolean false from external numeric fields', async ({ pa const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); expect(cells.slice(-4)).toEqual(['', '', '', '']); -}); +}); \ No newline at end of file From f60715267a742e299468c34051a1720be11ad26b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:51:37 -0700 Subject: [PATCH 12/32] fix: scope numeric-zero preservation to numeric fields --- app.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 1fec2f23..c47afa05 100644 --- a/app.js +++ b/app.js @@ -56,6 +56,7 @@ const EDITABLE_FIELDS = [ 'sprint', 'storyPoints' ]; +const ZERO_VALID_FIELDS = new Set(['budget', 'actualCost', 'storyPoints']); const CSV_HEADERS = [ '단계', @@ -870,7 +871,7 @@ function renderEditorField(label, field, value, type = 'text', required = false, if (type === 'text') { input.maxLength = 1000; } - input.value = value === 0 ? '0' : (value || ''); + input.value = value === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (value || ''); if (required) { input.required = true; input.setAttribute('aria-required', 'true'); @@ -1311,7 +1312,8 @@ function sanitizeDraft(draft) { EDITABLE_FIELDS.forEach((field) => { // 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion const val = draft?.[field]; - sanitized[field] = String(val === 0 ? '0' : (val || '')).trim().slice(0, 1000); + const normalizedValue = val === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (val || ''); + sanitized[field] = String(normalizedValue).trim().slice(0, 1000); }); // 🛡️ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { @@ -1497,7 +1499,6 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); - function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); @@ -2247,7 +2248,6 @@ function trapGanttModalFocus(event) { event.preventDefault(); return; } - const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = document.activeElement; @@ -2763,4 +2763,4 @@ if (typeof window !== 'undefined') { window.createTextCellContent = createTextCellContent; } -bootstrap(); +bootstrap(); \ No newline at end of file From b803db1d260be4579b9c03f53d7714194821c6ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:07:28 +0900 Subject: [PATCH 13/32] fix: keep Hono lockfile aligned --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00a99254..a1cb4dd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", - "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", "license": "MIT", "engines": { "node": ">=16.9.0" From 6c8b28274b7972dac806d21491ba93f7496a1aa9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 05:13:38 -0700 Subject: [PATCH 14/32] test: cover numeric zero in JSON sync --- tests/e2e/numeric-zero-roundtrip.spec.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 90de6753..d7eb49ed 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -155,6 +155,29 @@ test('preserves numeric zero while normalizing an external wbs.json record', asy await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); }); +test('preserves numeric zero fields in wbs.json sync output', async ({ page }) => { + await page.addInitScript(() => { + window.__savedWbsJson = null; + window.showSaveFilePicker = async () => ({ + async createWritable() { + return { + async write(content) { + window.__savedWbsJson = content; + }, + async close() {}, + }; + }, + }); + }); + await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + + await page.getByRole('button', { name: 'wbs.json 자동저장 연결' }).click(); + await expect.poll(async () => page.evaluate(() => window.__savedWbsJson)).not.toBeNull(); + const savedPayload = await page.evaluate(() => JSON.parse(window.__savedWbsJson)); + + expect(savedPayload[0]).toMatchObject({ budget: 0, actualCost: 0, storyPoints: 0 }); +}); + test('exports numeric zero values instead of empty CSV cells', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); From 5c34c79017ad36ccc56831a35fee8aa4dad091cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:20:33 +0900 Subject: [PATCH 15/32] fix: preserve numeric fields in JSON sync --- app.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index c47afa05..c40f7440 100644 --- a/app.js +++ b/app.js @@ -2208,7 +2208,10 @@ function exportJsonArray() { [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, actualProgressStatus: task.actualProgressStatus, actualStartDate: task.actualStartDate, - actualEndDate: task.actualEndDate + actualEndDate: task.actualEndDate, + budget: task.budget === 0 ? 0 : (task.budget || ''), + actualCost: task.actualCost === 0 ? 0 : (task.actualCost || ''), + storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || '') })); } @@ -2763,4 +2766,4 @@ if (typeof window !== 'undefined') { window.createTextCellContent = createTextCellContent; } -bootstrap(); \ No newline at end of file +bootstrap(); From df7ef929c5c10b201218a70ee00b89ede76fed59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 05:27:44 -0700 Subject: [PATCH 16/32] test: cover planning metadata JSON sync --- tests/e2e/numeric-zero-roundtrip.spec.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index d7eb49ed..1e91e800 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -155,7 +155,7 @@ test('preserves numeric zero while normalizing an external wbs.json record', asy await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); }); -test('preserves numeric zero fields in wbs.json sync output', async ({ page }) => { +test('preserves planning metadata in wbs.json sync output', async ({ page }) => { await page.addInitScript(() => { window.__savedWbsJson = null; window.showSaveFilePicker = async () => ({ @@ -169,13 +169,25 @@ test('preserves numeric zero fields in wbs.json sync output', async ({ page }) = }, }); }); - await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + await seedPersistedTask(page, { + budget: 0, + actualCost: 0, + storyPoints: 0, + sprint: 'Sprint 3', + predecessors: 'P1000,P2000', + }); await page.getByRole('button', { name: 'wbs.json 자동저장 연결' }).click(); await expect.poll(async () => page.evaluate(() => window.__savedWbsJson)).not.toBeNull(); const savedPayload = await page.evaluate(() => JSON.parse(window.__savedWbsJson)); - expect(savedPayload[0]).toMatchObject({ budget: 0, actualCost: 0, storyPoints: 0 }); + expect(savedPayload[0]).toMatchObject({ + budget: 0, + actualCost: 0, + storyPoints: 0, + sprint: 'Sprint 3', + predecessors: 'P1000,P2000', + }); }); test('exports numeric zero values instead of empty CSV cells', async ({ page }) => { From 40f26217609dd5836a91ab535c01ead266143078 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 21:40:29 +0900 Subject: [PATCH 17/32] fix: preserve planning metadata in JSON sync --- app.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app.js b/app.js index c40f7440..64a133ee 100644 --- a/app.js +++ b/app.js @@ -2209,8 +2209,10 @@ function exportJsonArray() { actualProgressStatus: task.actualProgressStatus, actualStartDate: task.actualStartDate, actualEndDate: task.actualEndDate, + predecessors: task.predecessors ?? '', budget: task.budget === 0 ? 0 : (task.budget || ''), actualCost: task.actualCost === 0 ? 0 : (task.actualCost || ''), + sprint: task.sprint ?? '', storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || '') })); } From 4c873b1a542e5837affec2dc8c6663c166309e84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 06:51:07 -0700 Subject: [PATCH 18/32] test: reject falsy nonnumeric JSON sync metadata --- tests/e2e/numeric-zero-roundtrip.spec.js | 37 +++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 1e91e800..3bd77148 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -190,6 +190,41 @@ test('preserves planning metadata in wbs.json sync output', async ({ page }) => }); }); +test('normalizes unrelated falsy planning metadata in wbs.json sync output', async ({ page }) => { + await page.addInitScript(() => { + window.__savedWbsJson = null; + window.showSaveFilePicker = async () => ({ + async createWritable() { + return { + async write(content) { + window.__savedWbsJson = content; + }, + async close() {}, + }; + }, + }); + }); + await seedPersistedTask(page, { + predecessors: 0, + sprint: false, + budget: 0, + actualCost: 0, + storyPoints: 0, + }); + + await page.getByRole('button', { name: 'wbs.json 자동저장 연결' }).click(); + await expect.poll(async () => page.evaluate(() => window.__savedWbsJson)).not.toBeNull(); + const savedPayload = await page.evaluate(() => JSON.parse(window.__savedWbsJson)); + + expect(savedPayload[0]).toMatchObject({ + predecessors: '', + sprint: '', + budget: 0, + actualCost: 0, + storyPoints: 0, + }); +}); + test('exports numeric zero values instead of empty CSV cells', async ({ page }) => { await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); @@ -218,4 +253,4 @@ test('does not preserve boolean false from external numeric fields', async ({ pa const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); expect(cells.slice(-4)).toEqual(['', '', '', '']); -}); \ No newline at end of file +}); From 3bb2bd95905d590fe3d1d0d9a8fc6c6ec133c04a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 23:33:16 +0900 Subject: [PATCH 19/32] fix: normalize nonnumeric sync metadata --- app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 64a133ee..fef4555f 100644 --- a/app.js +++ b/app.js @@ -2209,10 +2209,10 @@ function exportJsonArray() { actualProgressStatus: task.actualProgressStatus, actualStartDate: task.actualStartDate, actualEndDate: task.actualEndDate, - predecessors: task.predecessors ?? '', + predecessors: task.predecessors || '', budget: task.budget === 0 ? 0 : (task.budget || ''), actualCost: task.actualCost === 0 ? 0 : (task.actualCost || ''), - sprint: task.sprint ?? '', + sprint: task.sprint || '', storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || '') })); } From 0ead5e6c5f53dc74c6cb2666e36d35153ce2fd91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:56:04 -0700 Subject: [PATCH 20/32] fix(security): align Hono with 4.13.5 advisories --- package-lock.json | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1cb4dd8..f507096b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.5" }, "devDependencies": { "@playwright/test": "1.62.1", @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", - "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 6326e7af..215c9623 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,11 @@ }, "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.5" }, "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file From bd538f05df8203577a980bd7f56af1fab4040018 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 10:03:23 +0900 Subject: [PATCH 21/32] fix: restore modulepreload contracts --- index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/index.html b/index.html index d24b2a88..acce6789 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + From 3566962e12e02f82ea1ca53f4115dee0394cc19c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:14:39 -0700 Subject: [PATCH 22/32] test: reproduce JSON sync bootstrap race --- tests/e2e/numeric-zero-roundtrip.spec.js | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js index 3bd77148..f29788b6 100644 --- a/tests/e2e/numeric-zero-roundtrip.spec.js +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -78,6 +78,69 @@ const parseFirstCsvDataRow = (csvText) => { return dataRow.split(',').map((cell) => cell.slice(1, -1).replace(/""/g, '"')); }; +test('blocks wbs.json sync until cloud hydration finishes', async ({ page }) => { + let releaseProjectHydration; + const projectHydration = new Promise((resolve) => { + releaseProjectHydration = resolve; + }); + + await page.route('**/api/projects', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ projects: [{ id: 123, name: 'Hydration Project', archived: false }] }), + }); + }); + await page.route('**/api/notifications', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notifications: [] }), + }); + }); + await page.route('**/api/projects/123', async (route) => { + await projectHydration; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 123, + orgId: 1, + name: 'Hydration Project', + baseDate: '2026-08-26', + version: 1, + tasks: [createTask({ budget: 0 })], + }), + }); + }); + await page.addInitScript(() => { + localStorage.setItem('scopeweave:token', 'test-token'); + localStorage.setItem('scopeweave:project', '123'); + window.__pickerCalls = 0; + window.showSaveFilePicker = async () => { + window.__pickerCalls += 1; + return { + async createWritable() { + return { + async write() {}, + async close() {}, + }; + }, + }; + }; + }); + + await page.goto('./'); + const syncButton = page.getByRole('button', { name: 'wbs.json 자동저장 연결' }); + await expect(syncButton).toBeDisabled(); + await syncButton.evaluate((button) => button.click()); + expect(await page.evaluate(() => window.__pickerCalls)).toBe(0); + + releaseProjectHydration(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); + await expect(syncButton).toBeEnabled(); +}); + for (const { field, testId } of [ { field: 'budget', testId: 'editor-budget' }, { field: 'actualCost', testId: 'editor-actual-cost' }, From 9ef18de47a26eaa319462a2aa3cf17e3d442fae6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:17:11 -0700 Subject: [PATCH 23/32] fix: gate JSON sync until planner hydration completes --- json-sync-bootstrap-guard.js | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 json-sync-bootstrap-guard.js diff --git a/json-sync-bootstrap-guard.js b/json-sync-bootstrap-guard.js new file mode 100644 index 00000000..aea2c461 --- /dev/null +++ b/json-sync-bootstrap-guard.js @@ -0,0 +1,38 @@ +const syncButton = document.getElementById('connect-json-sync'); +const taskTableBody = document.getElementById('task-table-body'); + +const LOADING_TITLE = '프로젝트 데이터를 불러오는 중입니다.'; +const UNSUPPORTED_TITLE = '이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'; + +/** + * Keep direct wbs.json writes fail-closed until the planner has rendered its + * hydrated state. The table body always receives either task rows or the empty + * state row from renderAll(), so its first rendered child is the bootstrap + * completion signal for both populated and intentionally empty projects. + */ +function updateJsonSyncAvailability() { + const bootstrapRendered = taskTableBody.childElementCount > 0; + const pickerSupported = typeof window.showSaveFilePicker === 'function'; + const ready = bootstrapRendered && pickerSupported; + + syncButton.disabled = !ready; + if (ready) { + syncButton.removeAttribute('aria-disabled'); + syncButton.title = ''; + return; + } + + syncButton.setAttribute('aria-disabled', 'true'); + syncButton.title = bootstrapRendered ? UNSUPPORTED_TITLE : LOADING_TITLE; +} + +updateJsonSyncAvailability(); + +const bootstrapObserver = new MutationObserver(() => { + updateJsonSyncAvailability(); + if (taskTableBody.childElementCount > 0) { + bootstrapObserver.disconnect(); + } +}); + +bootstrapObserver.observe(taskTableBody, { childList: true }); From 6ec8bef03df8cf94f66f4dc0e673e4d2da3b33e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:18:43 -0700 Subject: [PATCH 24/32] fix: load JSON sync bootstrap guard fail-closed --- index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index acce6789..0c947ae2 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ ScopeWeave Planner + @@ -48,7 +49,7 @@

ScopeWeave Planner

브라우저 로컬 자동저장 사용 중 - +
@@ -114,6 +115,7 @@

간트 차트

+ From d97808c12a966d68f3e6bce88b26da8c867908ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:24:27 -0700 Subject: [PATCH 25/32] test: require cloud serving for JSON sync guard --- tests/api/static-json-sync-guard.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/api/static-json-sync-guard.test.mjs diff --git a/tests/api/static-json-sync-guard.test.mjs b/tests/api/static-json-sync-guard.test.mjs new file mode 100644 index 00000000..8fa3e367 --- /dev/null +++ b/tests/api/static-json-sync-guard.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +test('cloud server serves the JSON sync bootstrap guard as JavaScript', async () => { + const response = await app.request('/json-sync-bootstrap-guard.js'); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^text\/javascript\b/); + assert.match(await response.text(), /updateJsonSyncAvailability/); +}); From 45d5eb27d0749e7698580c360f0936bb3e3a4664 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:27:51 -0700 Subject: [PATCH 26/32] fix: serve JSON sync bootstrap guard --- server/app.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index c432a84f..c0fbe2e7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1302,7 +1302,7 @@ app.get('/api/projects/:id/baselines', requireAuth, (c) => { const baselines = db.prepare( 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' ).all(p.id); - return c.json({ baselines }); + return c.json({ baselines, methodology: p.methodology || 'waterfall' }); }); app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { @@ -1392,6 +1392,7 @@ const STATIC = { '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], '/pricing': ['landing.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/json-sync-bootstrap-guard.js': ['json-sync-bootstrap-guard.js', 'text/javascript; charset=utf-8'], '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], '/styles.css': ['styles.css', 'text/css; charset=utf-8'], From 4c965cb04a9ae5d970b7d61afcbb8caf32197d06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 18:30:55 -0700 Subject: [PATCH 27/32] test: run JSON sync static route regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 215c9623..5dd76fa8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "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: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 && node tests/api/static-json-sync-guard.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", From 5a905d41bb077ce54359943ddd82a349933bc14f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:50:17 -0700 Subject: [PATCH 28/32] test: fail when JSON sync guard escapes coverage --- tests/unit/coverage-script-contract.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..57cb5fac 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,21 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=json-sync-bootstrap-guard\.js/, + 'the JSON sync bootstrap guard is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/json-sync-bootstrap-guard\.test\.mjs/, + 'the JSON sync bootstrap guard behavior executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, From 2bc9ae246cf9d878136cb501b4be346c23ab31ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:50:59 -0700 Subject: [PATCH 29/32] test: cover JSON sync bootstrap guard behavior --- tests/unit/json-sync-bootstrap-guard.test.mjs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/unit/json-sync-bootstrap-guard.test.mjs diff --git a/tests/unit/json-sync-bootstrap-guard.test.mjs b/tests/unit/json-sync-bootstrap-guard.test.mjs new file mode 100644 index 00000000..93caf05d --- /dev/null +++ b/tests/unit/json-sync-bootstrap-guard.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; + +const attributes = new Map(); +const syncButton = { + disabled: false, + title: '', + setAttribute(name, value) { + attributes.set(name, value); + }, + removeAttribute(name) { + attributes.delete(name); + }, +}; +const taskTableBody = { childElementCount: 0 }; + +let observerCallback = null; +let observedTarget = null; +let observedOptions = null; +let disconnectCalls = 0; + +class FakeMutationObserver { + constructor(callback) { + observerCallback = callback; + } + + observe(target, options) { + observedTarget = target; + observedOptions = options; + } + + disconnect() { + disconnectCalls += 1; + } +} + +globalThis.document = { + getElementById(id) { + if (id === 'connect-json-sync') { + return syncButton; + } + if (id === 'task-table-body') { + return taskTableBody; + } + return null; + }, +}; +globalThis.window = {}; +globalThis.MutationObserver = FakeMutationObserver; + +try { + await import('../../json-sync-bootstrap-guard.js'); + + assert.equal(syncButton.disabled, true); + assert.equal(attributes.get('aria-disabled'), 'true'); + assert.equal(syncButton.title, '프로젝트 데이터를 불러오는 중입니다.'); + assert.equal(observedTarget, taskTableBody); + assert.deepEqual(observedOptions, { childList: true }); + assert.equal(typeof observerCallback, 'function'); + + observerCallback(); + assert.equal(disconnectCalls, 0); + assert.equal(syncButton.disabled, true); + assert.equal(syncButton.title, '프로젝트 데이터를 불러오는 중입니다.'); + + taskTableBody.childElementCount = 1; + observerCallback(); + assert.equal(disconnectCalls, 1); + assert.equal(syncButton.disabled, true); + assert.equal(attributes.get('aria-disabled'), 'true'); + assert.equal(syncButton.title, '이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'); + + globalThis.window.showSaveFilePicker = async () => {}; + observerCallback(); + assert.equal(disconnectCalls, 2); + assert.equal(syncButton.disabled, false); + assert.equal(attributes.has('aria-disabled'), false); + assert.equal(syncButton.title, ''); +} finally { + delete globalThis.document; + delete globalThis.window; + delete globalThis.MutationObserver; +} + +console.log('✓ JSON sync bootstrap guard behavior tests passed'); From 10c91abcbe2bf2468c05536fd4280e2466546464 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:51:44 -0700 Subject: [PATCH 30/32] fix(ci): measure JSON sync bootstrap guard coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 5dd76fa8..e687c5d4 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "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 && node tests/api/static-json-sync-guard.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/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/json-sync-bootstrap-guard.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=json-sync-bootstrap-guard.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/json-sync-bootstrap-guard.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 tests/e2e/numeric-zero-roundtrip.spec.js", From f8c31782afe53a9108c260cd2a026a6679dbc7e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:54:03 -0700 Subject: [PATCH 31/32] test(ci): fail when required workflow skips coverage --- tests/unit/coverage-script-contract.test.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 57cb5fac..6e0a72cb 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,6 +1,6 @@ -// This contract prevents a subtle CI regression: the central review gate may -// invoke `test:coverage` directly, so that script itself must create Istanbul -// JSON rather than merely execute tests without instrumentation. +// These contracts prevent CI from producing a false-green coverage signal: +// the canonical script must emit Istanbul evidence and the required Server Tests +// workflow must actually execute that script on every pull request. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -8,6 +8,10 @@ const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); const scripts = packageJson.scripts; +const serverTestsWorkflow = readFileSync( + new URL('../../.github/workflows/server-tests.yml', import.meta.url), + 'utf8', +); assert.equal( scripts.coverage, @@ -54,5 +58,10 @@ assert.doesNotMatch( /npm run (?:coverage|test:coverage)(?:\s|$)/, 'coverage cases never recursively invoke a coverage wrapper', ); +assert.match( + serverTestsWorkflow, + /name:\s+Owned production coverage[\s\S]*?run:\s+npm run test:coverage\b/, + 'the required Server Tests workflow executes owned production coverage', +); console.log('✓ coverage script contract tests passed'); From 5c1f7eced40cef872b28f59e3d434ebc3cd6c17a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:55:53 -0700 Subject: [PATCH 32/32] test(ci): keep workflow coverage repair in owning lane --- tests/unit/coverage-script-contract.test.mjs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 6e0a72cb..57cb5fac 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,6 +1,6 @@ -// These contracts prevent CI from producing a false-green coverage signal: -// the canonical script must emit Istanbul evidence and the required Server Tests -// workflow must actually execute that script on every pull request. +// This contract prevents a subtle CI regression: the central review gate may +// invoke `test:coverage` directly, so that script itself must create Istanbul +// JSON rather than merely execute tests without instrumentation. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -8,10 +8,6 @@ const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); const scripts = packageJson.scripts; -const serverTestsWorkflow = readFileSync( - new URL('../../.github/workflows/server-tests.yml', import.meta.url), - 'utf8', -); assert.equal( scripts.coverage, @@ -58,10 +54,5 @@ assert.doesNotMatch( /npm run (?:coverage|test:coverage)(?:\s|$)/, 'coverage cases never recursively invoke a coverage wrapper', ); -assert.match( - serverTestsWorkflow, - /name:\s+Owned production coverage[\s\S]*?run:\s+npm run test:coverage\b/, - 'the required Server Tests workflow executes owned production coverage', -); console.log('✓ coverage script contract tests passed');