diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 458d3aa9..d404d0df 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -55,3 +55,5 @@ jobs: run: npx playwright install chromium --with-deps - name: Cloud UI e2e run: npm run test:e2e:cloud + - name: Metadata render performance benchmark + run: npm run test:e2e:performance diff --git a/.jules/bolt.md b/.jules/bolt.md index b08b203a..ebf10f08 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -4,3 +4,9 @@ ## 2026-07-12 - Optimize renderTaskRow DOM allocations **Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly. **Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers. +## 2026-07-13 - Cache immutable DOM shells, not input-bearing nodes +**Learning:** In high-frequency rendering loops, repeated `document.createElement()` calls and repeated configuration of static structure create avoidable DOM bridge and GC overhead. Templates must contain only immutable, non-customer-specific structure. Caching fully configured nodes keyed by owner names, labels, descriptions, titles, or accessible names retains row/user data in detached DOM and turns input cardinality into memory retention. +**Action:** Cache one bounded immutable shell per structural element type, clone it in the hot path, and apply row-specific text, classes, titles, and accessibility attributes only to the returned clone. Use fixed stylesheet classes for deterministic visual variants instead of inline styles or input-keyed DOM caches. +## 2026-07-13 - Correctly caching element attributes with cloneNode +**Learning:** Both `Node.cloneNode(false)` and `Node.cloneNode(true)` copy HTML attributes and their values, including reflected properties such as `title`. The `deep` argument controls only whether child nodes are cloned. JavaScript extension properties and listeners registered with `addEventListener()` are not cloned. +**Action:** Select shallow or deep cloning from the required child-node structure, not to preserve reflected attributes. Reapply JavaScript extension properties and event listeners explicitly when cached templates require them. diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..46d57002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +- Added a reproducible 5,000-row production-browser rendering benchmark that + records median and p95 duration, long tasks, heap deltas, live DOM nodes, + element creation, and edit, drag, and inline-progress interaction evidence. ### Security @@ -61,6 +64,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 mode, delegating provider/model/topology policy to the shared service without weakening ScopeWeave's authenticated, fail-closed transport or response boundary controls. +- Reused one immutable owner-badge shell and one immutable status-badge shell in + the WBS table render path. Row text, accessible descriptions, and deterministic + owner color classes are applied only after cloning, so detached templates do + not retain task/user values or inline color styles. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/app.js b/app.js index a04aae71..7915bf89 100644 --- a/app.js +++ b/app.js @@ -2,12 +2,7 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1'; const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner'; const MAX_PROJECT_NAME_LENGTH = 120; const MAX_BASE_DATE_LENGTH = 10; -const OWNER_COLORS = [ - '#3f51b5', '#8e24aa', '#d81b60', '#ef6c00', '#6d4c41', - '#00897b', '#1e88e5', '#3949ab', '#7cb342', '#f4511e', - '#5e35b1', '#c0ca33', '#00acc1', '#fb8c00', '#546e7a', - '#43a047', '#e53935', '#6a1b9a', '#039be5', '#5d4037' -]; +const OWNER_COLOR_CLASS_COUNT = 20; const ACTUAL_PROGRESS_OPTIONS = [ '미착수(0%)', @@ -263,7 +258,11 @@ async function bootstrap() { } function bindEvents() { - const persistAndRenderMetadata = debounce(() => { + const persistProjectMetadata = debounce(() => { + persistState(); + renderAll({ metadataOnly: true }); + }, 150); + const persistAndRenderPlan = debounce(() => { persistState(); renderAll(); }, 150); @@ -277,13 +276,13 @@ function bindEvents() { return true; }; - bindHeaderEvents(persistAndRenderMetadata); + bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan); bindModalEvents(); bindGlobalEvents(); bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent); } -function bindHeaderEvents(persistAndRenderMetadata) { +function bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan) { elements.projectNameInput.addEventListener('input', (event) => { const sanitized = String(event.target.value).slice(0, MAX_PROJECT_NAME_LENGTH); if (event.target.value !== sanitized) { @@ -292,15 +291,15 @@ function bindHeaderEvents(persistAndRenderMetadata) { event.target.setSelectionRange(cursor, cursor); } state.projectName = sanitized.trim() || DEFAULT_PROJECT_NAME; - persistAndRenderMetadata(); + persistProjectMetadata(); }); - elements.projectNameInput.addEventListener('blur', persistAndRenderMetadata.flush); + elements.projectNameInput.addEventListener('blur', persistProjectMetadata.flush); elements.baseDateInput.addEventListener('input', (event) => { state.baseDate = String(event.target.value).trim().slice(0, MAX_BASE_DATE_LENGTH) || formatLocalDateInput(new Date()); - persistAndRenderMetadata(); + persistAndRenderPlan(); }); - elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush); + elements.baseDateInput.addEventListener('blur', persistAndRenderPlan.flush); elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() })); elements.exportCsvButton.addEventListener('click', (e) => { @@ -502,17 +501,24 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); } -const cachedHasChildrenSet = new Set(); -function renderAll() { - const metrics = computeTaskMetrics(); - +function renderProjectMetadata() { elements.projectNameInput.value = state.projectName; document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`; elements.baseDateInput.value = state.baseDate; + elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중'; +} + +const cachedHasChildrenSet = new Set(); +function renderAll({ metadataOnly = false } = {}) { + renderProjectMetadata(); + if (metadataOnly) { + return; + } + + const metrics = computeTaskMetrics(); elements.totalDays.textContent = `${formatNumber(metrics.totalDays)}일`; elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2); elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2); - elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중'; if (typeof window !== 'undefined') { window.ScopeWeaveAnalytics?.render?.({ @@ -971,21 +977,32 @@ function createWarningBadge(warning) { return badge; } -const persistentOwnerColorMap = new Map(); +// Badge templates are immutable shells only. Customer/task text and accessible names +// are applied to each clone after cloning so cached detached nodes never retain row data. +let ownerBadgeTemplate = null; +let statusBadgeTemplate = null; + +function getOwnerColorIndex(owner) { + let hash = 0; + for (let index = 0; index < owner.length; index += 1) { + hash = ((hash << 5) - hash + owner.charCodeAt(index)) | 0; + } + return (hash >>> 0) % OWNER_COLOR_CLASS_COUNT; +} function createOwnerCellContent(owner) { if (!owner) { return createEmptyCell(); } + const ownerValue = String(owner); - if (!persistentOwnerColorMap.has(owner)) { - persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]); + if (!ownerBadgeTemplate) { + ownerBadgeTemplate = document.createElement('span'); + ownerBadgeTemplate.className = 'owner-badge'; } - - const badge = document.createElement('span'); - badge.className = 'owner-badge'; - badge.style.background = persistentOwnerColorMap.get(owner); - badge.textContent = owner; + const badge = ownerBadgeTemplate.cloneNode(false); + badge.className = `owner-badge owner-badge--color-${getOwnerColorIndex(ownerValue)}`; + badge.textContent = ownerValue; return badge; } @@ -993,7 +1010,12 @@ function createStatusCellContent(progressState) { if (!progressState.label) { return createEmptyCell(); } - const badge = document.createElement('span'); + + if (!statusBadgeTemplate) { + statusBadgeTemplate = document.createElement('span'); + statusBadgeTemplate.className = 'status-badge'; + } + const badge = statusBadgeTemplate.cloneNode(false); badge.className = `status-badge ${progressState.className}`; badge.textContent = progressState.label; if (progressState.description) { diff --git a/docs/doctoring/dom-template-cache.md b/docs/doctoring/dom-template-cache.md new file mode 100644 index 00000000..6324e4a9 --- /dev/null +++ b/docs/doctoring/dom-template-cache.md @@ -0,0 +1,109 @@ +# Immutable DOM badge shells and browser evidence + +## Decision status + +This record describes an **active pull-request implementation**, not protected-`develop` +truth until integration completes. ScopeWeave may reuse unattached owner/status badge +shells in the WBS render loop only when all of the following remain true: + +- every returned node is a clone rather than the cached shell itself; +- cached shells contain no task, owner, status, title, description, accessible name, + or other row-specific value; +- row-specific text, classes, `title`, and `aria-label` values are applied only after + cloning; +- owner colors use a fixed set of stylesheet classes rather than inline styles or an + owner-value registry; +- empty cells and warning paths keep their existing semantics; and +- production-browser interaction tests accompany allocation-focused unit tests. + +The optimization is deliberately limited to small immutable badge structures. +Editable controls, validation relationships, and elements whose event listeners or +mutable child state differ per row are not cached here. + +## DOM correctness and privacy boundary + +`cloneNode()` copies the node and its attributes. Its `deep` argument controls whether +child nodes are copied; it does not transfer listeners registered through +`addEventListener()`. ScopeWeave therefore keeps the two cached badge shells free of +row-specific attributes and child text, clones them shallowly, and mutates only the +returned clone. + +This boundary is also a data-retention control. Owner names and status explanations +are not used as DOM-cache keys and are not retained in detached template nodes. +High-cardinality customer values therefore cannot grow a detached-node cache or leave +historical row text in reusable templates. + +## Resource bound and deterministic color + +The owner badge uses one immutable shell and the status badge uses one immutable +shell. Their memory bound is therefore structural rather than an input-cardinality +LRU limit. A deterministic integer hash maps an owner string to one of 20 fixed +`owner-badge--color-N` classes defined in `styles.css`; the shell itself contains no +owner value and no inline `background` style. + +This supersedes the earlier 256-entry input-keyed owner/status template maps. That +approach bounded entry count but still retained task/user data in cache keys and +detached DOM nodes. + +## Metadata-only render integration + +Project-name persistence remains on the single user-visible `renderAll()` integration +path. `renderAll({ metadataOnly: true })` refreshes project metadata and returns before +metric calculation, analytics, visible-task construction, and task-grid replacement. +Base-date changes continue through the full render path because they affect schedule +metrics. + +## Test-first evidence contract + +The focused unit contract verifies that: + +- cached owner/status shells do not retain row text, title, accessible name, or inline + color; +- returned nodes are distinct clones populated with the correct current row value; +- a changed status description appears on the returned clone without mutating the + cached shell; +- 300 unique status values and 300 unique owners allocate no new template elements + after their respective shell is initialized; +- 5,000 identical owners likewise allocate no new template elements after shell + initialization; and +- empty-value behavior remains unchanged. + +The Playwright benchmark drives the production bootstrap and rendering path with 5,000 +rows. For each warm project-name edit it records duration, `document.createElement()` +calls, heap delta when available, live DOM-node count, and whether the first task-row +node retained identity. The candidate contract requires **every** warm metadata sample +to create zero elements and preserve task-grid identity. Edit, inline-progress, and +drag/reorder probes remain acceptance checks in the same browser run. + +## Evidence interpretation + +A prior hosted A/B run demonstrated a large metadata-edit improvement against its then +protected base, but predecessor-head or predecessor-base success is not exact-current- +head evidence. After any source, test, documentation, stylesheet, or base-reconciliation +change, the PR must regenerate browser and repository-native evidence for the unchanged +exact contributor head and independently resolved live protected base before the result +can support merge or release. + +Cold-load and long-task values remain diagnostic unless the benchmark is explicitly +designed and powered for claims about those outcomes. The performance claim for this +slice is limited to the project-name metadata-edit hot path. + +## Rollback + +Revert the immutable badge-shell helpers, fixed owner color classes, focused unit +contract, metadata-sample assertion, browser benchmark registration, changelog entry, +and this record together. A rollback does not change persisted WBS data or server APIs. + +## References + +Mozilla. (2026). *Node: cloneNode() method*. MDN Web Docs. +https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode + +Web Hypertext Application Technology Working Group. (2026). *DOM standard*. +https://dom.spec.whatwg.org/ + +World Wide Web Consortium. (2017). *Long Tasks API 1*. +https://www.w3.org/TR/longtasks-1/ + +World Wide Web Consortium. (2024). *High Resolution Time Level 3*. +https://www.w3.org/TR/hr-time-3/ diff --git a/index.html b/index.html index d24b2a88..acce6789 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + diff --git a/package.json b/package.json index 8cefdc74..d334622f 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "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: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:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/render-performance-base.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/caching.test.mjs && node tests/unit/owner-badge-contrast.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: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 && node tests/unit/caching.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:performance": "playwright install chromium && playwright test tests/e2e/render-performance.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/styles.css b/styles.css index 9d715f00..a321a093 100644 --- a/styles.css +++ b/styles.css @@ -404,6 +404,27 @@ select:focus-visible, text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); } +.owner-badge--color-0 { background: #3f51b5; } +.owner-badge--color-1 { background: #8e24aa; } +.owner-badge--color-2 { background: #d81b60; } +.owner-badge--color-3 { background: #ba5400; } +.owner-badge--color-4 { background: #6d4c41; } +.owner-badge--color-5 { background: #008073; } +.owner-badge--color-6 { background: #1a74c4; } +.owner-badge--color-7 { background: #3949ab; } +.owner-badge--color-8 { background: #567d2e; } +.owner-badge--color-9 { background: #cb4319; } +.owner-badge--color-10 { background: #5e35b1; } +.owner-badge--color-11 { background: #71771e; } +.owner-badge--color-12 { background: #007e8e; } +.owner-badge--color-13 { background: #ab5f00; } +.owner-badge--color-14 { background: #546e7a; } +.owner-badge--color-15 { background: #368139; } +.owner-badge--color-16 { background: #d43531; } +.owner-badge--color-17 { background: #6a1b9a; } +.owner-badge--color-18 { background: #0278b2; } +.owner-badge--color-19 { background: #5d4037; } + .status-badge.before { background: #f1f5f9; color: var(--status-before); } .status-badge.active { background: #d1fae5; color: #047857; } .status-badge.done { background: #e2e8f0; color: var(--status-done); } @@ -1109,4 +1130,4 @@ tbody tr.cpm-critical { background: rgba(234, 88, 12, 0.04); } .pm-recommendations li + li { margin-top: 4px; -} +} \ No newline at end of file diff --git a/tests/e2e/render-performance.spec.js b/tests/e2e/render-performance.spec.js new file mode 100644 index 00000000..ec0b3e97 --- /dev/null +++ b/tests/e2e/render-performance.spec.js @@ -0,0 +1,269 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import { test, expect } from '@playwright/test'; + +import { resolveBenchmarkBaseSha } from '../helpers/benchmark-base.mjs'; + +const ROW_COUNT = 5_000; +const SAMPLE_COUNT = 5; +const STORAGE_KEY = 'scopeweave:planner-state:v1'; +const TARGET_IMPROVEMENT_PERCENT = 15; + +function percentile(values, probability) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * probability) - 1); + return sorted[index]; +} + +function createTask(index) { + return { + id: `performance-${index}`, + parentId: null, + depth: 1, + expanded: true, + pendingDelete: false, + isSynthetic: false, + phase: `Phase ${index}`, + activity: '', + task: '', + categoryLarge: '', + categoryMedium: '', + documentName: '', + owner: 'same-owner', + supportTeam: '', + plannedStartDate: '2026-01-01', + plannedEndDate: '2026-01-02', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: '', + budget: '', + actualCost: '', + sprint: '', + storyPoints: '', + }; +} + +function benchmarkBaseSha() { + const eventPath = process.env.GITHUB_EVENT_PATH; + const event = eventPath ? JSON.parse(readFileSync(eventPath, 'utf8')) : {}; + return resolveBenchmarkBaseSha({ + override: process.env.SCOPEWEAVE_BENCHMARK_BASE_SHA, + event, + }); +} + +function readGitFile(commitSha, path) { + const normalizedCommitSha = String(commitSha || ''); + if (!/^[a-f0-9]{40}$/.test(normalizedCommitSha)) { + throw new Error(`Invalid benchmark base SHA: ${normalizedCommitSha || ''}`); + } + + const spec = `${normalizedCommitSha}:${path}`; + try { + return execFileSync('git', ['show', spec], { encoding: 'utf8' }); + } catch { + execFileSync('git', ['fetch', '--depth=1', 'origin', normalizedCommitSha], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return execFileSync('git', ['show', spec], { encoding: 'utf8' }); + } +} + +async function measureRenderer(browser, { appSource = null, label }) { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.addInitScript(() => { + const originalCreateElement = Document.prototype.createElement; + let createElementCalls = 0; + Document.prototype.createElement = function (...args) { + createElementCalls += 1; + return originalCreateElement.apply(this, args); + }; + window.__scopeweaveCreateElementCalls = () => createElementCalls; + window.__scopeweaveLongTasks = []; + if (PerformanceObserver.supportedEntryTypes?.includes('longtask')) { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + window.__scopeweaveLongTasks.push(entry.duration); + } + }); + observer.observe({ type: 'longtask', buffered: true }); + } + }); + + if (appSource !== null) { + await page.route('**/app.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript; charset=utf-8', + body: appSource, + }); + }); + } + + await page.goto('/'); + const tasks = Array.from({ length: ROW_COUNT }, (_, index) => createTask(index)); + await page.evaluate(({ storageKey, seededTasks, benchmarkLabel }) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: `ScopeWeave ${benchmarkLabel} benchmark`, + baseDate: '2026-01-01', + tasks: seededTasks, + })); + }, { storageKey: STORAGE_KEY, seededTasks: tasks, benchmarkLabel: label }); + + const coldStartedAt = Date.now(); + await page.reload(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(ROW_COUNT); + const coldLoadDurationMs = Date.now() - coldStartedAt; + + const evidence = await page.evaluate(async ({ sampleCount, benchmarkLabel }) => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const projectName = document.getElementById('project-name'); + const samples = []; + + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const firstRowBeforeMetadataEdit = document.querySelector('tr[data-task-id="performance-0"]'); + const createElementsBefore = window.__scopeweaveCreateElementCalls(); + const heapBefore = performance.memory?.usedJSHeapSize ?? null; + const startedAt = performance.now(); + projectName.focus(); + projectName.value = `ScopeWeave ${benchmarkLabel} benchmark ${sampleIndex}`; + projectName.dispatchEvent(new Event('input', { bubbles: true })); + projectName.blur(); + await nextFrame(); + samples.push({ + durationMs: performance.now() - startedAt, + createElementCalls: window.__scopeweaveCreateElementCalls() - createElementsBefore, + heapDeltaBytes: heapBefore === null ? null : performance.memory.usedJSHeapSize - heapBefore, + liveDomNodes: document.getElementsByTagName('*').length, + taskGridReused: firstRowBeforeMetadataEdit === document.querySelector('tr[data-task-id="performance-0"]'), + }); + } + + const firstRow = document.querySelector('tr[data-task-id="performance-0"]'); + firstRow.querySelector('button[data-action="edit"]').click(); + const editOpened = Boolean(document.querySelector('form[data-editor-form="true"]')); + document.querySelector('button[data-action="cancel-editor"]').click(); + + let progressSelect = document.querySelector('select[data-inline-progress="performance-0"]'); + const nextProgress = progressSelect.options[Math.min(1, progressSelect.options.length - 1)].value; + progressSelect.value = nextProgress; + progressSelect.dispatchEvent(new Event('change', { bubbles: true })); + progressSelect = document.querySelector('select[data-inline-progress="performance-0"]'); + const inlineProgressChanged = progressSelect.value === nextProgress; + + const rowIds = () => Array.from(document.querySelectorAll('tr[data-task-id]'), (row) => row.dataset.taskId); + const orderBeforeDrag = rowIds().slice(0, 2); + const sourceRow = document.querySelector('tr[data-task-id="performance-0"]'); + const targetRow = document.querySelector('tr[data-task-id="performance-1"]'); + const dataTransfer = new DataTransfer(); + sourceRow.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer })); + const targetRect = targetRow.getBoundingClientRect(); + targetRow.dispatchEvent(new DragEvent('dragover', { + bubbles: true, + cancelable: true, + clientY: targetRect.bottom, + dataTransfer, + })); + targetRow.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientY: targetRect.bottom, + dataTransfer, + })); + sourceRow.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer })); + await nextFrame(); + const orderAfterDrag = rowIds().slice(0, 2); + + return { + samples, + longTasks: window.__scopeweaveLongTasks, + renderedRows: document.querySelectorAll('tr[data-task-id]').length, + editOpened, + inlineProgressChanged, + dragReordered: orderBeforeDrag.join(',') !== orderAfterDrag.join(','), + }; + }, { sampleCount: SAMPLE_COUNT, benchmarkLabel: label }); + + await context.close(); + return { coldLoadDurationMs, evidence }; +} + +function summarizeMeasurement(measurement) { + const durations = measurement.evidence.samples.map((sample) => sample.durationMs); + const createElementCalls = measurement.evidence.samples.map((sample) => sample.createElementCalls); + return { + coldLoadDurationMs: measurement.coldLoadDurationMs, + sampleDurationsMs: durations, + medianDurationMs: percentile(durations, 0.5), + p95DurationMs: percentile(durations, 0.95), + medianCreateElementCalls: percentile(createElementCalls, 0.5), + metadataTaskGridReused: measurement.evidence.samples.every((sample) => sample.taskGridReused), + longTaskCount: measurement.evidence.longTasks.length, + longestTaskMs: measurement.evidence.longTasks.length + ? Math.max(...measurement.evidence.longTasks) + : null, + heapDeltaBytes: measurement.evidence.samples.map((sample) => sample.heapDeltaBytes), + liveDomNodes: measurement.evidence.samples.map((sample) => sample.liveDomNodes), + createElementCalls, + editOpened: measurement.evidence.editOpened, + inlineProgressChanged: measurement.evidence.inlineProgressChanged, + dragReordered: measurement.evidence.dragReordered, + }; +} + +test('5,000-row production rendering beats the exact protected-base median by at least 15%', async ({ browser }) => { + test.setTimeout(180_000); + + const baseSha = benchmarkBaseSha(); + const baselineSource = readGitFile(baseSha, 'app.js'); + const optimizedMeasurement = await measureRenderer(browser, { label: 'optimized' }); + const optimized = summarizeMeasurement(optimizedMeasurement); + const baseline = summarizeMeasurement(await measureRenderer(browser, { + appSource: baselineSource, + label: 'protected-base', + })); + const optimizationDeltaPercent = ((baseline.medianDurationMs - optimized.medianDurationMs) + / baseline.medianDurationMs) * 100; + const targetMet = optimizationDeltaPercent >= TARGET_IMPROVEMENT_PERCENT; + + const report = { + rowCount: ROW_COUNT, + sampleCount: SAMPLE_COUNT, + protectedBaseSha: baseSha, + protectedBaselineAvailable: true, + targetPercent: TARGET_IMPROVEMENT_PERCENT, + targetMet, + optimizationDeltaPercent, + baseline, + optimized, + comparisonNote: 'Both variants use the same browser, current static shell, 5,000-row state, and render trigger; only app.js is replaced with the immutable PR-base or previous protected-branch source for the baseline.', + }; + console.log(`SCOPEWEAVE_RENDER_BENCHMARK ${JSON.stringify(report)}`); + + expect(optimizedMeasurement.evidence.samples).toHaveLength(SAMPLE_COUNT); + expect(optimizedMeasurement.evidence.renderedRows).toBe(ROW_COUNT); + expect(optimized.metadataTaskGridReused).toBe(true); + for (const sample of optimizedMeasurement.evidence.samples) { + expect(sample.createElementCalls).toBe(0); + } + expect(optimized.medianDurationMs).toBeGreaterThan(0); + expect(optimized.p95DurationMs).toBeGreaterThanOrEqual(optimized.medianDurationMs); + expect(optimized.editOpened).toBe(true); + expect(optimized.inlineProgressChanged).toBe(true); + expect(optimized.dragReordered).toBe(true); + + expect(baseline.medianDurationMs).toBeGreaterThan(0); + expect(baseline.editOpened).toBe(true); + expect(baseline.inlineProgressChanged).toBe(true); + expect(baseline.dragReordered).toBe(true); + expect(optimized.medianCreateElementCalls).toBeLessThan(baseline.medianCreateElementCalls); + expect( + optimizationDeltaPercent, + `expected >=${TARGET_IMPROVEMENT_PERCENT}% median render improvement over ${baseSha}, got ${optimizationDeltaPercent.toFixed(2)}%`, + ).toBeGreaterThanOrEqual(TARGET_IMPROVEMENT_PERCENT); +}); diff --git a/tests/helpers/benchmark-base.mjs b/tests/helpers/benchmark-base.mjs new file mode 100644 index 00000000..83a2d3db --- /dev/null +++ b/tests/helpers/benchmark-base.mjs @@ -0,0 +1,37 @@ +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; +const ZERO_COMMIT_SHA = '0'.repeat(40); + +function canonicalCommitSha(value) { + const sha = String(value || '').trim().toLowerCase(); + if (!COMMIT_SHA_PATTERN.test(sha) || sha === ZERO_COMMIT_SHA) { + throw new Error(`Benchmark base SHA is invalid: ${sha || ''}`); + } + return sha; +} + +/** + * Resolve the immutable revision that a performance run must compare against. + * + * Pull-request runs compare to the PR base snapshot that triggered the run. + * Protected-branch push runs compare to the immediately previous protected + * commit from the push event. Operators may provide an explicit immutable SHA + * when replaying the benchmark outside those GitHub event shapes. + * + * @param {{override?: unknown, event?: unknown}} input benchmark authority input + * @returns {string} canonical 40-character commit SHA + */ +export function resolveBenchmarkBaseSha({ override, event } = {}) { + const explicit = String(override || '').trim(); + if (explicit) return canonicalCommitSha(explicit); + + const eventObject = event && typeof event === 'object' && !Array.isArray(event) + ? event + : {}; + const pullRequestBase = eventObject.pull_request?.base?.sha; + if (pullRequestBase) return canonicalCommitSha(pullRequestBase); + + const pushBefore = eventObject.before; + if (pushBefore) return canonicalCommitSha(pushBefore); + + throw new Error('Benchmark base SHA is unavailable; provide an immutable comparison revision.'); +} diff --git a/tests/unit/caching.test.mjs b/tests/unit/caching.test.mjs new file mode 100644 index 00000000..250d1625 --- /dev/null +++ b/tests/unit/caching.test.mjs @@ -0,0 +1,319 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const appJsPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'app.js'); + +function loadApp() { + let createElementCalls = 0; + let source = fs.readFileSync(appJsPath, 'utf8'); + source = source.replace(/^\s*bootstrap\(\);\s*$/m, ';'); + source += ` +;globalThis.__cachingExports = { + createStatusCellContent, + createOwnerCellContent, + createActualProgressCellContent, + getStatusBadgeTemplate: () => statusBadgeTemplate, + getOwnerBadgeTemplate: () => ownerBadgeTemplate, + getActualProgressSelectTemplate: () => actualProgressSelectTemplate, +}; +`; + + class DummyNode { + constructor(name) { + this.name = name; + this.attributes = Object.create(null); + this.dataset = Object.create(null); + this.style = Object.create(null); + this.children = []; + this.valueAttribute = undefined; + } + set className(value) { this.attributes.class = value; } + get className() { return this.attributes.class; } + set textContent(value) { this.text = value; } + get textContent() { return this.text; } + set title(value) { this.titleAttribute = value; } + get title() { return this.titleAttribute; } + set value(value) { + if (this.name !== 'select') { + this.valueAttribute = value; + return; + } + const normalized = String(value); + this.valueAttribute = this.children.some((child) => String(child.value) === normalized) + ? normalized + : ''; + } + get value() { + if (this.name !== 'select') return this.valueAttribute; + if (this.valueAttribute) return this.valueAttribute; + return this.children.find((child) => child.name === 'option')?.value ?? ''; + } + setAttribute(key, value) { this.attributes[key] = value; } + appendChild(child) { this.children.push(child); } + append(...children) { this.children.push(...children); } + cloneNode(deep) { + const node = new DummyNode(this.name); + node.attributes = { ...this.attributes }; + node.dataset = { ...this.dataset }; + node.style = { ...this.style }; + node.titleAttribute = this.titleAttribute; + node.id = this.id; + if (deep) { + node.text = this.text; + node.children = this.children.map((child) => child.cloneNode(true)); + } + node.value = this.value; + return node; + } + } + + const dummyElement = new DummyNode('div'); + const classList = { + contains: () => false, + add() {}, + remove() {}, + toggle() {}, + }; + const proxyDummy = new Proxy(dummyElement, { + get(target, property) { + if (property === 'classList') return classList; + if (property in target) return target[property]; + return () => proxyDummy; + }, + set(target, property, value) { + target[property] = value; + return true; + }, + }); + + const sandbox = { + document: { + createElement: (name) => { + createElementCalls += 1; + return new DummyNode(name); + }, + getElementById: () => proxyDummy, + querySelector: () => proxyDummy, + querySelectorAll: () => [], + body: proxyDummy, + addEventListener() {}, + }, + window: { + addEventListener() {}, + setTimeout: () => 0, + clearTimeout: () => undefined, + confirm: () => true, + }, + localStorage: { + getItem: () => null, + setItem: () => undefined, + }, + console, + setTimeout: () => 0, + clearTimeout: () => undefined, + Math, + Object, + Array, + String, + Number, + Boolean, + Map, + Set, + WeakMap, + Symbol, + Error, + TypeError, + Date, + JSON, + Proxy, + Promise, + }; + sandbox.globalThis = sandbox; + + const context = vm.createContext(sandbox); + vm.runInContext(source, context, { filename: appJsPath }); + + return { + ...sandbox.__cachingExports, + getCreateElementCalls: () => createElementCalls, + }; +} + +const { + createStatusCellContent, + createOwnerCellContent, + createActualProgressCellContent, + getStatusBadgeTemplate, + getOwnerBadgeTemplate, + getActualProgressSelectTemplate, + getCreateElementCalls, +} = loadApp(); + +const doneState = { + label: '완료', + className: 'done', + description: '실적이 모두 입력되어 완료된 작업입니다.', +}; + +const firstDoneCell = createStatusCellContent(doneState); +assert.equal(firstDoneCell.text, '완료'); +assert.equal(firstDoneCell.className, 'status-badge done'); +assert.equal(firstDoneCell.title, doneState.description); +assert.equal( + firstDoneCell.attributes['aria-label'], + `완료 - ${doneState.description}`, +); +const statusShell = getStatusBadgeTemplate(); +assert.equal(statusShell.className, 'status-badge'); +assert.equal(statusShell.textContent, undefined, 'cached status shell must not retain row text'); +assert.equal(statusShell.title, undefined, 'cached status shell must not retain row title'); +assert.equal( + statusShell.attributes['aria-label'], + undefined, + 'cached status shell must not retain row accessibility text', +); + +const equivalentDoneCell = createStatusCellContent({ ...doneState }); +assert.notEqual(firstDoneCell, equivalentDoneCell); +assert.equal(equivalentDoneCell.text, '완료'); +assert.equal(getStatusBadgeTemplate(), statusShell, 'status rendering reuses one immutable shell'); + +const revisedDescription = '완료되었지만 검토가 필요한 작업입니다.'; +const revisedDoneCell = createStatusCellContent({ + ...doneState, + description: revisedDescription, +}); +assert.equal(revisedDoneCell.title, revisedDescription); +assert.equal( + revisedDoneCell.attributes['aria-label'], + `완료 - ${revisedDescription}`, +); +assert.equal(statusShell.textContent, undefined, 'status shell remains free of revised row text'); +assert.equal(statusShell.title, undefined, 'status shell remains free of revised row descriptions'); + +const createElementCallsBeforeStatuses = getCreateElementCalls(); +for (let index = 0; index < 300; index += 1) { + createStatusCellContent({ + label: `status-${index}`, + className: `state-${index}`, + description: `description-${index}`, + }); +} +assert.equal( + getCreateElementCalls() - createElementCallsBeforeStatuses, + 0, + 'status values clone one immutable shell without allocating per-value templates', +); +assert.equal(statusShell.textContent, undefined, 'status shell never retains customer status values'); + +const emptyState = { label: '', className: '', description: '' }; +const emptyCell = createStatusCellContent(emptyState); +assert.equal(emptyCell.name, 'span'); +assert.equal(emptyCell.className, 'empty-cell'); + +const firstOwnerCell = createOwnerCellContent('홍길동'); +assert.equal(firstOwnerCell.text, '홍길동'); +assert.match(firstOwnerCell.className, /^owner-badge owner-badge--color-\d+$/); +assert.equal(firstOwnerCell.style.background, undefined, 'owner color must not use inline style'); +const ownerShell = getOwnerBadgeTemplate(); +assert.equal(ownerShell.className, 'owner-badge'); +assert.equal(ownerShell.textContent, undefined, 'cached owner shell must not retain user data'); +assert.equal(ownerShell.style.background, undefined, 'cached owner shell must not retain inline color'); + +const secondOwnerCell = createOwnerCellContent('홍길동'); +assert.notEqual(firstOwnerCell, secondOwnerCell); +assert.equal(secondOwnerCell.text, '홍길동'); +assert.equal(firstOwnerCell.className, secondOwnerCell.className, 'owner color class stays deterministic'); +assert.equal(getOwnerBadgeTemplate(), ownerShell, 'owner rendering reuses one immutable shell'); + +const numericOwnerCell = createOwnerCellContent(123); +assert.equal(numericOwnerCell.text, '123', 'owner values are stringified before hashing and rendering'); +assert.match(numericOwnerCell.className, /^owner-badge owner-badge--color-\d+$/); +const minimumSignedHashOwner = String.fromCharCode(2, 13, 0, 9, 30, 12, 2); +const minimumSignedHashCell = createOwnerCellContent(minimumSignedHashOwner); +assert.match( + minimumSignedHashCell.className, + /^owner-badge owner-badge--color-(?:[0-9]|1[0-9])$/, + 'owner hash overflow still selects a defined palette class', +); + +const createElementCallsBeforeOwners = getCreateElementCalls(); +for (let index = 0; index < 300; index += 1) { + const ownerCell = createOwnerCellContent(`owner-${index}`); + assert.match(ownerCell.className, /^owner-badge owner-badge--color-\d+$/); +} +assert.equal( + getCreateElementCalls() - createElementCallsBeforeOwners, + 0, + 'unique owner values clone one immutable shell without allocating user-keyed templates', +); +assert.equal(ownerShell.textContent, undefined, 'owner shell never retains customer owner values'); + +const createElementCallsBeforeVolume = getCreateElementCalls(); +for (let rowIndex = 0; rowIndex < 5_000; rowIndex += 1) { + createOwnerCellContent('same-owner'); +} +assert.equal( + getCreateElementCalls() - createElementCallsBeforeVolume, + 0, + '5,000 identical owner rows clone the existing immutable shell without new elements', +); + +const emptyOwner = createOwnerCellContent(''); +assert.equal(emptyOwner.className, 'empty-cell'); + +// Issue #409 also requires the pre-existing cached progress