From 64dc2f90b37e7cb925986d15921e4062c05fb690 Mon Sep 17 00:00:00 2001 From: Francisco Alejandro Garcia Cebada Date: Mon, 12 Oct 2026 17:02:18 -0700 Subject: [PATCH 1/2] feat: Workers Summary --- FASTBuildMonitorVSCode/README.md | 1 + .../webview-ui/package.json | 1 + .../webview-ui/src/monitor_panel/App.css | 24 ++++-- .../webview-ui/src/monitor_panel/App.tsx | 18 +++- .../src/monitor_panel/components/Timeline.tsx | 72 ++-------------- .../webview-ui/src/monitor_panel/coreRows.ts | 83 +++++++++++++++++++ .../webview-ui/tests/coreRows.test.ts | 73 ++++++++++++++++ 7 files changed, 198 insertions(+), 74 deletions(-) create mode 100644 FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts create mode 100644 FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts diff --git a/FASTBuildMonitorVSCode/README.md b/FASTBuildMonitorVSCode/README.md index 51dea37..27aa1f6 100644 --- a/FASTBuildMonitorVSCode/README.md +++ b/FASTBuildMonitorVSCode/README.md @@ -17,6 +17,7 @@ - **Timeline/Gantt chart** — Canvas-rendered visualization of build jobs across workers, color-coded by status. Bar labels use black text for at least 4.5:1 contrast against every status fill in all themes, including legacy mode. - **Job tooltips** — Hover over any job bar to see name, host, status, and duration - **Worker panel** — Live active/total/cached job counts per worker host +- **Workers summary** — Monitor footer shows remote worker count, remote cores, and local cores. Core counts reflect peak concurrent job slots observed per host in the current build, matching the timeline, rather than hardware core counts. - **Build statistics** — Total, active, succeeded, cached, and failed counts - **Progress bar** — Overall build progress percentage with automatically computed black/white text colors for the filled and unfilled portions. The label follows the animated fill boundary and theme changes without an added background. - **Recent jobs list** — Scrollable feed of the latest jobs with status and timing diff --git a/FASTBuildMonitorVSCode/webview-ui/package.json b/FASTBuildMonitorVSCode/webview-ui/package.json index 13b874b..b081499 100644 --- a/FASTBuildMonitorVSCode/webview-ui/package.json +++ b/FASTBuildMonitorVSCode/webview-ui/package.json @@ -6,6 +6,7 @@ "start": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "node --experimental-transform-types --test tests\\coreRows.test.ts", "lint": "eslint src --ext .ts,.tsx" }, "dependencies": { diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css index f0892e6..011e9fb 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css @@ -455,15 +455,29 @@ body.vscode-dark:not(.legacy-colors) { text-align: right; } -/* Log path info */ +/* Worker summary and log path */ +.monitor-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 12px; + padding: 4px 12px; + background: var(--toolbar-bg); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +.workers-summary { + font-size: 12px; + color: var(--text); +} + .log-path { + flex: 1 1 200px; + min-width: 0; font-size: 10px; color: var(--text-dim); - padding: 2px 12px; - background: var(--toolbar-bg); - border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex-shrink: 0; } diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx index 0300469..85a3281 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { MonitorSnapshot } from './types'; +import { getCoreRows, getWorkerSummary } from './coreRows'; import Toolbar, { getStatusText, formatElapsedCompact } from './components/Toolbar'; import ProgressBar from './components/ProgressBar'; import Timeline from './components/Timeline'; @@ -55,6 +56,8 @@ function App() { }, []); const session = snapshot.session; + const coreRows = useMemo(() => getCoreRows(session), [session]); + const { workerCount, remoteCores, localCores } = getWorkerSummary(coreRows); const progress = session ? session.progress : 0; const elapsed = session ? formatElapsedCompact((session.endTime || Date.now()) - session.startTime) @@ -82,13 +85,24 @@ function App() {
-
Log: {snapshot.logPath || '(unknown)'}
+
+ + {workerCount} workers - {remoteCores} remote cores - {localCores} local cores + + + Log: {snapshot.logPath || '(unknown)'} + +
); } diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx index 1c1ded3..74fb1da 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { useEffect, useRef, useCallback } from 'react'; +import type { CoreRow } from '../coreRows'; import { BuildJob, BuildSession, @@ -21,73 +22,11 @@ const RIGHT_PAD = 100; interface TimelineProps { session?: BuildSession; + coreRows: CoreRow[]; timeScale: number; onTimeScaleChange: (scale: number) => void; } -interface CoreRow { - hostName: string; - coreIndex: number; - jobs: BuildJob[]; -} - -// Assign jobs to virtual "cores" per host via first-fit scheduling, so that -// each row in the timeline represents a single execution slot that runs at -// most one job at a time. The number of cores per host therefore equals the -// peak number of concurrent jobs that ran on that host. -function getCoreRows(session: BuildSession): CoreRow[] { - const sortedJobs = [...session.jobs].sort((a, b) => a.startTime - b.startTime); - - interface CoreSlot { jobs: BuildJob[]; lastEndTime: number; } - const hostCores = new Map(); - const displayNameByKey = new Map(); - - for (const job of sortedJobs) { - const key = job.hostName.toLowerCase(); - if (!displayNameByKey.has(key)) { - displayNameByKey.set(key, job.hostName); - } - let cores = hostCores.get(key); - if (!cores) { - cores = []; - hostCores.set(key, cores); - } - - const jobEnd = job.endTime ?? Date.now(); - let assigned = false; - for (const core of cores) { - if (core.lastEndTime <= job.startTime) { - core.jobs.push(job); - core.lastEndTime = jobEnd; - assigned = true; - break; - } - } - if (!assigned) { - cores.push({ jobs: [job], lastEndTime: jobEnd }); - } - } - - // Local host first, then remaining hosts alphabetically. - const hostKeys = Array.from(hostCores.keys()).sort((a, b) => { - const aLocal = a === 'local'; - const bLocal = b === 'local'; - if (aLocal && !bLocal) return -1; - if (!aLocal && bLocal) return 1; - return a.localeCompare(b); - }); - - const rows: CoreRow[] = []; - for (const key of hostKeys) { - const cores = hostCores.get(key)!; - const displayName = displayNameByKey.get(key) || key; - cores.forEach((core, idx) => { - rows.push({ hostName: displayName, coreIndex: idx, jobs: core.jobs }); - }); - } - return rows; -} - function getMaxTime(session: BuildSession): number { let max = Date.now(); if (session.endTime) { @@ -196,6 +135,7 @@ function setupCanvas( export default function Timeline({ session, + coreRows, timeScale, onTimeScaleChange, }: TimelineProps) { @@ -241,7 +181,6 @@ export default function Timeline({ const dpr = window.devicePixelRatio || 1; const containerWidth = container.clientWidth; const viewportContentWidth = Math.max(1, containerWidth - LABEL_WIDTH); - const coreRows = session ? getCoreRows(session) : []; const bodyHeight = Math.max(MIN_BODY_HEIGHT, coreRows.length * ROW_HEIGHT + PAD * 2); // Resolve theme-aware colors from CSS custom properties so the canvas @@ -434,7 +373,7 @@ export default function Timeline({ labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0); labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight); labelsCtx.stroke(); - }, [session, timeScale]); + }, [session, coreRows, timeScale]); useEffect(() => { draw(); @@ -497,7 +436,6 @@ export default function Timeline({ // are drawn in). const mx = e.clientX - rect.left + container.scrollLeft; const my = e.clientY - rect.top; - const coreRows = getCoreRows(session); const startTime = session.startTime; let tooltipJob: BuildJob | undefined = undefined; @@ -528,7 +466,7 @@ export default function Timeline({ canvas.title = ''; } }, - [session, timeScale] + [session, coreRows, timeScale] ); return ( diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts new file mode 100644 index 0000000..7a947dd --- /dev/null +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/coreRows.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { BuildJob, BuildSession } from './types'; + +export interface CoreRow { + hostName: string; + coreIndex: number; + jobs: BuildJob[]; +} + +// Assign jobs to virtual "cores" per host via first-fit scheduling, so that +// each row represents a single execution slot. Core counts are the peak +// concurrency observed in the log, not the host's hardware core count. +export function getCoreRows(session?: BuildSession): CoreRow[] { + if (!session) return []; + const sortedJobs = [...session.jobs].sort((a, b) => a.startTime - b.startTime); + + interface CoreSlot { jobs: BuildJob[]; lastEndTime: number; } + const hostCores = new Map(); + const displayNameByKey = new Map(); + + for (const job of sortedJobs) { + const key = job.hostName.toLowerCase(); + if (!displayNameByKey.has(key)) { + displayNameByKey.set(key, job.hostName); + } + let cores = hostCores.get(key); + if (!cores) { + cores = []; + hostCores.set(key, cores); + } + + const jobEnd = job.endTime ?? Date.now(); + let assigned = false; + for (const core of cores) { + if (core.lastEndTime <= job.startTime) { + core.jobs.push(job); + core.lastEndTime = jobEnd; + assigned = true; + break; + } + } + if (!assigned) { + cores.push({ jobs: [job], lastEndTime: jobEnd }); + } + } + + // Local host first, then remaining hosts alphabetically. + const hostKeys = Array.from(hostCores.keys()).sort((a, b) => { + const aLocal = a === 'local'; + const bLocal = b === 'local'; + if (aLocal && !bLocal) return -1; + if (!aLocal && bLocal) return 1; + return a.localeCompare(b); + }); + + const rows: CoreRow[] = []; + for (const key of hostKeys) { + const cores = hostCores.get(key)!; + const displayName = displayNameByKey.get(key) || key; + cores.forEach((core, idx) => { + rows.push({ hostName: displayName, coreIndex: idx, jobs: core.jobs }); + }); + } + return rows; +} + +export function getWorkerSummary(rows: CoreRow[]) { + const workers = new Set(); + let remoteCores = 0; + let localCores = 0; + for (const row of rows) { + const host = row.hostName.toLowerCase(); + if (host === 'local') { + localCores++; + } else { + workers.add(host); + remoteCores++; + } + } + return { workerCount: workers.size, remoteCores, localCores }; +} diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts new file mode 100644 index 0000000..8984924 --- /dev/null +++ b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { getCoreRows, getWorkerSummary } from '../src/monitor_panel/coreRows.ts'; +import { BuildJobStatus } from '../src/monitor_panel/types.ts'; +import type { BuildJob, BuildSession } from '../src/monitor_panel/types.ts'; + +function job(hostName: string, startTime: number, endTime?: number): BuildJob { + return { + hostName, startTime, endTime, eventName: `${hostName}-${startTime}`, + status: endTime === undefined ? BuildJobStatus.Building : BuildJobStatus.Success, + }; +} + +function session(jobs: BuildJob[], endTime?: number): BuildSession { + return { processId: 1, logVersion: 1, startTime: 0, progress: 0, jobs, endTime }; +} + +test('no session or an empty build has zero workers and cores', () => { + const expected = { workerCount: 0, remoteCores: 0, localCores: 0 }; + assert.deepEqual(getWorkerSummary(getCoreRows()), expected); + assert.deepEqual(getWorkerSummary(getCoreRows(session([]))), expected); +}); + +test('local jobs do not count as remote workers and adjacent jobs reuse a core', () => { + const rows = getCoreRows(session([ + job('LOCAL', 10, 20), job('local', 0, 10), job('Local', 5, 15), + ])); + assert.deepEqual(getWorkerSummary(rows), { workerCount: 0, remoteCores: 0, localCores: 2 }); + assert.deepEqual(rows[0].jobs.map(j => j.startTime), [0, 10]); +}); + +test('remote core counts sum per-host peaks, not simultaneous global activity', () => { + const build = session([ + job('worker-b', 20, 30), job('worker-a', 0, 10), + job('WORKER-A', 5, 15), job('worker-b', 21, 31), job('worker-b', 22, 32), + ], 32); + const originalOrder = [...build.jobs]; + const rows = getCoreRows(build); + assert.deepEqual(getWorkerSummary(rows), { workerCount: 2, remoteCores: 5, localCores: 0 }); + assert.deepEqual(build.jobs, originalOrder); + assert.deepEqual(rows.map(r => r.coreIndex), [0, 1, 0, 1, 2]); + assert.deepEqual(rows.map(r => r.hostName), [ + 'worker-a', 'worker-a', 'worker-b', 'worker-b', 'worker-b', + ]); +}); + +test('active jobs occupy separate cores and completed builds retain peak counts', () => { + const build = session([job('local', 0), job('worker', 1), job('WORKER', 2)]); + const expected = { workerCount: 1, remoteCores: 2, localCores: 1 }; + assert.deepEqual(getWorkerSummary(getCoreRows(build)), expected); + for (const j of build.jobs) { + j.endTime = 10; + j.status = BuildJobStatus.Stopped; + } + build.endTime = 10; + assert.deepEqual(getWorkerSummary(getCoreRows(build)), expected); + assert.deepEqual(getWorkerSummary(getCoreRows(session([job('local', 20, 30)]))), + { workerCount: 0, remoteCores: 0, localCores: 1 }); +}); + +test('matches the standalone example: 10 workers, 30 remote cores, 64 local cores', () => { + const jobs = Array.from({ length: 64 }, () => job('local', 0, 100)); + for (let i = 0; i < 10; i++) { + jobs.push(...Array.from({ length: 3 }, () => job(`worker-${i}`, 0, 100))); + } + const rows = getCoreRows(session(jobs, 100)); + assert.deepEqual(getWorkerSummary(rows), { workerCount: 10, remoteCores: 30, localCores: 64 }); + assert.equal(rows.length, 94); + assert.equal(rows[0].hostName, 'local'); +}); From 087df1432c4127e76d3159d0198be4e6bc69b0e1 Mon Sep 17 00:00:00 2001 From: Francisco Alejandro Garcia Cebada Date: Tue, 13 Oct 2026 11:34:34 -0700 Subject: [PATCH 2/2] - Integrating unit test to vitest --- .../webview-ui/package.json | 1 - .../webview-ui/tests/coreRows.test.ts | 37 ++++++++------- .../webview-ui/tests/timeline.test.tsx | 46 +++++++++++-------- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/FASTBuildMonitorVSCode/webview-ui/package.json b/FASTBuildMonitorVSCode/webview-ui/package.json index b2a4c02..8130018 100644 --- a/FASTBuildMonitorVSCode/webview-ui/package.json +++ b/FASTBuildMonitorVSCode/webview-ui/package.json @@ -6,7 +6,6 @@ "start": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "node --experimental-transform-types --test tests\\coreRows.test.ts", "lint": "eslint src --ext .ts,.tsx" }, "dependencies": { diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts index 8984924..d969f42 100644 --- a/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts +++ b/FASTBuildMonitorVSCode/webview-ui/tests/coreRows.test.ts @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { strict as assert } from 'node:assert'; -import { test } from 'node:test'; -import { getCoreRows, getWorkerSummary } from '../src/monitor_panel/coreRows.ts'; -import { BuildJobStatus } from '../src/monitor_panel/types.ts'; -import type { BuildJob, BuildSession } from '../src/monitor_panel/types.ts'; +import { expect, test } from 'vitest'; +import { getCoreRows, getWorkerSummary } from '../src/monitor_panel/coreRows'; +import { BuildJobStatus } from '../src/monitor_panel/types'; +import type { BuildJob, BuildSession } from '../src/monitor_panel/types'; function job(hostName: string, startTime: number, endTime?: number): BuildJob { return { @@ -20,16 +19,16 @@ function session(jobs: BuildJob[], endTime?: number): BuildSession { test('no session or an empty build has zero workers and cores', () => { const expected = { workerCount: 0, remoteCores: 0, localCores: 0 }; - assert.deepEqual(getWorkerSummary(getCoreRows()), expected); - assert.deepEqual(getWorkerSummary(getCoreRows(session([]))), expected); + expect(getWorkerSummary(getCoreRows())).toStrictEqual(expected); + expect(getWorkerSummary(getCoreRows(session([])))).toStrictEqual(expected); }); test('local jobs do not count as remote workers and adjacent jobs reuse a core', () => { const rows = getCoreRows(session([ job('LOCAL', 10, 20), job('local', 0, 10), job('Local', 5, 15), ])); - assert.deepEqual(getWorkerSummary(rows), { workerCount: 0, remoteCores: 0, localCores: 2 }); - assert.deepEqual(rows[0].jobs.map(j => j.startTime), [0, 10]); + expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 0, remoteCores: 0, localCores: 2 }); + expect(rows[0].jobs.map(j => j.startTime)).toStrictEqual([0, 10]); }); test('remote core counts sum per-host peaks, not simultaneous global activity', () => { @@ -39,10 +38,10 @@ test('remote core counts sum per-host peaks, not simultaneous global activity', ], 32); const originalOrder = [...build.jobs]; const rows = getCoreRows(build); - assert.deepEqual(getWorkerSummary(rows), { workerCount: 2, remoteCores: 5, localCores: 0 }); - assert.deepEqual(build.jobs, originalOrder); - assert.deepEqual(rows.map(r => r.coreIndex), [0, 1, 0, 1, 2]); - assert.deepEqual(rows.map(r => r.hostName), [ + expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 2, remoteCores: 5, localCores: 0 }); + expect(build.jobs).toStrictEqual(originalOrder); + expect(rows.map(r => r.coreIndex)).toStrictEqual([0, 1, 0, 1, 2]); + expect(rows.map(r => r.hostName)).toStrictEqual([ 'worker-a', 'worker-a', 'worker-b', 'worker-b', 'worker-b', ]); }); @@ -50,14 +49,14 @@ test('remote core counts sum per-host peaks, not simultaneous global activity', test('active jobs occupy separate cores and completed builds retain peak counts', () => { const build = session([job('local', 0), job('worker', 1), job('WORKER', 2)]); const expected = { workerCount: 1, remoteCores: 2, localCores: 1 }; - assert.deepEqual(getWorkerSummary(getCoreRows(build)), expected); + expect(getWorkerSummary(getCoreRows(build))).toStrictEqual(expected); for (const j of build.jobs) { j.endTime = 10; j.status = BuildJobStatus.Stopped; } build.endTime = 10; - assert.deepEqual(getWorkerSummary(getCoreRows(build)), expected); - assert.deepEqual(getWorkerSummary(getCoreRows(session([job('local', 20, 30)]))), + expect(getWorkerSummary(getCoreRows(build))).toStrictEqual(expected); + expect(getWorkerSummary(getCoreRows(session([job('local', 20, 30)])))).toStrictEqual( { workerCount: 0, remoteCores: 0, localCores: 1 }); }); @@ -67,7 +66,7 @@ test('matches the standalone example: 10 workers, 30 remote cores, 64 local core jobs.push(...Array.from({ length: 3 }, () => job(`worker-${i}`, 0, 100))); } const rows = getCoreRows(session(jobs, 100)); - assert.deepEqual(getWorkerSummary(rows), { workerCount: 10, remoteCores: 30, localCores: 64 }); - assert.equal(rows.length, 94); - assert.equal(rows[0].hostName, 'local'); + expect(getWorkerSummary(rows)).toStrictEqual({ workerCount: 10, remoteCores: 30, localCores: 64 }); + expect(rows.length).toBe(94); + expect(rows[0].hostName).toBe('local'); }); diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx b/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx index bee1c51..474a810 100644 --- a/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/tests/timeline.test.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import Timeline from '../src/monitor_panel/components/Timeline'; +import { getCoreRows } from '../src/monitor_panel/coreRows'; import { BuildJobStatus } from '../src/monitor_panel/types'; import { canvasContext, getContext, ResizeObserverMock } from './setup'; import { job, session } from './fixtures'; @@ -22,7 +23,7 @@ function canvas(container: HTMLElement, selector: string) { describe('timeline rendering and interaction', () => { it('renders an empty state and releases resize observers on unmount', () => { - const { container, unmount } = render(); + const { container, unmount } = render(); const body = canvas(container, '.timeline-body canvas'); expect(canvasContext(body).fillText).toHaveBeenCalledWith('Waiting for FASTBuild data...', 410, 84); const observer = ResizeObserverMock.instances[0]; @@ -45,7 +46,8 @@ describe('timeline rendering and interaction', () => { job({ hostName: 'worker-b', startTime: 4000, endTime: 6000 }), ]; const original = [...jobs]; - const { container } = render(); + const build = session({ jobs }); + const { container } = render(); const labels = canvasContext(canvas(container, '.timeline-labels canvas')); expect(labels.fillText.mock.calls.map(([text]) => text)).toEqual([ 'LOCAL (Core # 0)', 'LOCAL (Core # 1)', 'worker-a (Core # 0)', @@ -60,22 +62,25 @@ describe('timeline rendering and interaction', () => { job({ endTime: undefined, status: BuildJobStatus.Building }), job({ startTime: 2000, endTime: undefined, status: BuildJobStatus.Building }), ]; + const build = session({ jobs, endTime: undefined }); const { container, rerender } = render( - , + , ); let labels = canvasContext(canvas(container, '.timeline-labels canvas')); expect(labels.fillText).toHaveBeenCalledTimes(2); labels.fillText.mockClear(); - rerender( ({ ...j, endTime: 11000, status: BuildJobStatus.Stopped })) })} + const completedBuild = session({ jobs: jobs.map(j => ({ ...j, endTime: 11000, status: BuildJobStatus.Stopped })) }); + rerender(); labels = canvasContext(canvas(container, '.timeline-labels canvas')); expect(labels.fillText).toHaveBeenCalledTimes(2); }); it('shows and clears tooltips using the hovered row, filename, host, status and duration', () => { - const { container } = render(); + ] }); + const { container } = render(); const body = canvas(container, '.timeline-body canvas'); fireEvent.mouseMove(body, { clientX: 10, clientY: 10 }); expect(body.title).toBe('file.cpp (local) - Success - 5.0s'); @@ -88,9 +93,10 @@ describe('timeline rendering and interaction', () => { }); it('converts viewport hover coordinates into scrolled content coordinates', () => { - const { container } = render(); + ], endTime: 21000 }); + const { container } = render(); const scroller = container.querySelector('.timeline-container')!; scroller.scrollLeft = 500; const body = canvas(container, '.timeline-body canvas'); @@ -100,7 +106,7 @@ describe('timeline rendering and interaction', () => { it('zooms only with Ctrl+wheel and clamps scales to 1 through 200', () => { const change = vi.fn(); - const { container, rerender } = render(); + const { container, rerender } = render(); const scroller = container.querySelector('.timeline-container')!; fireEvent.wheel(scroller, { deltaY: -1 }); expect(change).not.toHaveBeenCalled(); @@ -108,23 +114,25 @@ describe('timeline rendering and interaction', () => { expect(change).toHaveBeenLastCalledWith(23); fireEvent.wheel(scroller, { deltaY: 1, ctrlKey: true }); expect(change).toHaveBeenLastCalledWith(20 / 1.15); - rerender(); + rerender(); fireEvent.wheel(scroller, { deltaY: -1, ctrlKey: true }); expect(change).toHaveBeenLastCalledWith(200); - rerender(); + rerender(); fireEvent.wheel(scroller, { deltaY: 1, ctrlKey: true }); expect(change).toHaveBeenLastCalledWith(1); }); it.each([1, 7, 15, 30, 50])('renders time-axis ticks at scale %i', timeScale => { - const { container } = render(); + const build = session(); + const { container } = render(); const header = canvasContext(canvas(container, '.timeline-header canvas')); expect(header.fillText).toHaveBeenCalledWith('0:00', 0, 16); }); it('keeps large, zoomed timelines viewport-sized and formats hour labels', () => { vi.stubGlobal('devicePixelRatio', 2); - const { container } = render(); + const build = session({ endTime: 7201000 }); + const { container } = render(); const body = canvas(container, '.timeline-body canvas'); const header = canvas(container, '.timeline-header canvas'); expect(body.width).toBe(1640); @@ -138,7 +146,7 @@ describe('timeline rendering and interaction', () => { it('stops auto-scroll when the user scrolls back and resumes near the right edge', () => { vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(10000); const build = session({ endTime: 101000 }); - const { container, rerender } = render(); + const { container, rerender } = render(); const scroller = container.querySelector('.timeline-container')!; expect(scroller.scrollLeft).toBe(1280); const body = canvasContext(canvas(container, '.timeline-body canvas')); @@ -147,7 +155,8 @@ describe('timeline rendering and interaction', () => { expect(body.translate).toHaveBeenCalledTimes(calls); scroller.scrollLeft = 50; fireEvent.scroll(scroller); - rerender(); + const updatedBuild = { ...build, endTime: 201000 }; + rerender(); expect(scroller.scrollLeft).toBe(50); scroller.scrollLeft = 9000; fireEvent.scroll(scroller); @@ -155,9 +164,10 @@ describe('timeline rendering and interaction', () => { }); it('handles sub-second jobs and job end times beyond the reported session end', () => { - const { container } = render(); + ] }); + const { container } = render(); const body = canvasContext(canvas(container, '.timeline-body canvas')); expect(body.fill).toHaveBeenCalledTimes(2); expect(body.fillText).toHaveBeenCalledWith('file.cpp', 3.5, 42); @@ -166,7 +176,7 @@ describe('timeline rendering and interaction', () => { it('does not draw when the browser cannot provide a canvas context', () => { getContext.mockReturnValueOnce(null); - const { container } = render(); + const { container } = render(); expect(canvasContext(canvas(container, '.timeline-body canvas')).fillText).not.toHaveBeenCalled(); }); });