From 2f5e895df8ad7098ec583a88ed4bbad0c597b2ae Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 10 Sep 2026 11:36:19 -0600 Subject: [PATCH] fix: make stress pointer probes ready before dispatch --- .../workflows/chart-library-benchmarks.yml | 40 +++- scripts/benchmark/stress-diagnostics.mjs | 22 +++ scripts/benchmark/stress-diagnostics.test.mjs | 33 ++++ scripts/benchmark/stress-pointer.mjs | 111 +++++++++++ scripts/benchmark/stress-pointer.test.mjs | 149 +++++++++++++++ scripts/ci-workflow.test.mjs | 37 ++++ scripts/classify-ci-changes.mjs | 2 + scripts/stress-chart-libraries.mjs | 174 ++++++++---------- 8 files changed, 465 insertions(+), 103 deletions(-) create mode 100644 scripts/benchmark/stress-diagnostics.mjs create mode 100644 scripts/benchmark/stress-diagnostics.test.mjs create mode 100644 scripts/benchmark/stress-pointer.mjs create mode 100644 scripts/benchmark/stress-pointer.test.mjs diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index fedce6a4..da1a8f01 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -9,6 +9,11 @@ on: - cron: '17 7 * * 1' workflow_dispatch: inputs: + diagnose_stats: + description: Run only the TanStack multi-series timing diagnostic, without retries or memory soak + required: false + type: boolean + default: false upload_bundle_baseline_candidate: description: Upload exact comparison bundle measurements required: false @@ -25,12 +30,13 @@ permissions: actions: read concurrency: - group: chart-library-benchmarks-${{ github.event_name }}-${{ github.ref }} + group: chart-library-benchmarks-${{ github.event_name }}-${{ github.ref }}${{ inputs.diagnose_stats && '-stats-diagnostic' || '' }} cancel-in-progress: true jobs: changes: name: Select CI partitions + if: github.event_name != 'workflow_dispatch' || !inputs.diagnose_stats runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -67,6 +73,7 @@ jobs: static: name: Static checks + if: github.event_name != 'workflow_dispatch' || !inputs.diagnose_stats runs-on: ubuntu-24.04 timeout-minutes: 15 env: @@ -187,7 +194,7 @@ jobs: bundle-baseline-candidate: name: Bundle baseline candidate - if: github.event_name == 'workflow_dispatch' && inputs.upload_bundle_baseline_candidate + if: github.event_name == 'workflow_dispatch' && inputs.upload_bundle_baseline_candidate && !inputs.diagnose_stats runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -274,7 +281,7 @@ jobs: compare-container-canary: name: Comparison container canary - if: github.event_name == 'workflow_dispatch' && inputs.playwright_container_canary + if: github.event_name == 'workflow_dispatch' && inputs.playwright_container_canary && !inputs.diagnose_stats runs-on: ubuntu-24.04 timeout-minutes: 20 container: @@ -303,9 +310,34 @@ jobs: if-no-files-found: error retention-days: 14 + stats-diagnostic: + name: Stats stress diagnostic + if: github.event_name == 'workflow_dispatch' && inputs.diagnose_stats + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup + uses: ./.github/actions/setup + with: + playwright: 'true' + - name: Diagnose one timing cell + run: node scripts/stress-chart-libraries.mjs --profile=standard --library=tanstack --workload=stats-multi-series-line --diagnostics + - name: Upload phase diagnostics + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: stats-stress-diagnostic-${{ github.run_id }} + path: .benchmark-output/stress/results + if-no-files-found: warn + retention-days: 7 + ci: name: CI - if: always() + if: always() && !inputs.diagnose_stats needs: - changes - static diff --git a/scripts/benchmark/stress-diagnostics.mjs b/scripts/benchmark/stress-diagnostics.mjs new file mode 100644 index 00000000..a22da502 --- /dev/null +++ b/scripts/benchmark/stress-diagnostics.mjs @@ -0,0 +1,22 @@ +export function createStressDiagnostics( + enabled, + emit = () => {}, + now = Date.now, +) { + const startedAt = now() + const phases = [] + return { + mark(phase) { + if (!enabled) return + const entry = { phase, elapsedMs: now() - startedAt } + phases.push(entry) + emit(entry) + }, + snapshot() { + return { elapsedMs: now() - startedAt, phases: [...phases] } + }, + lastPhase() { + return phases.at(-1)?.phase ?? 'not started' + }, + } +} diff --git a/scripts/benchmark/stress-diagnostics.test.mjs b/scripts/benchmark/stress-diagnostics.test.mjs new file mode 100644 index 00000000..9789e926 --- /dev/null +++ b/scripts/benchmark/stress-diagnostics.test.mjs @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { createStressDiagnostics } from './stress-diagnostics.mjs' + +describe('stress phase diagnostics', () => { + it('does not emit or retain phase records in normal runs', () => { + const emit = vi.fn() + const diagnostics = createStressDiagnostics(false, emit, () => 0) + diagnostics.mark('mount') + expect(emit).not.toHaveBeenCalled() + expect(diagnostics.snapshot().phases).toEqual([]) + }) + + it('retains elapsed phases independently of the final cell result', () => { + let time = 100 + const emit = vi.fn() + const diagnostics = createStressDiagnostics(true, emit, () => time) + diagnostics.mark('mount') + time = 150 + diagnostics.mark('pointer:initial:activate:0') + const snapshot = diagnostics.snapshot() + time = 200 + diagnostics.mark('complete') + expect(snapshot).toEqual({ + elapsedMs: 50, + phases: [ + { phase: 'mount', elapsedMs: 0 }, + { phase: 'pointer:initial:activate:0', elapsedMs: 50 }, + ], + }) + expect(emit).toHaveBeenCalledTimes(3) + expect(diagnostics.lastPhase()).toBe('complete') + }) +}) diff --git a/scripts/benchmark/stress-pointer.mjs b/scripts/benchmark/stress-pointer.mjs new file mode 100644 index 00000000..2f6ec771 --- /dev/null +++ b/scripts/benchmark/stress-pointer.mjs @@ -0,0 +1,111 @@ +// This function is also installed directly in the browser by Playwright. +export function installStressPointerTiming(host = globalThis) { + let pending + let cancel + + host.__stressPointerBegin = (mode, previousSignature) => { + if (pending) throw new Error('Pointer timing is already armed.') + if (mode !== 'activate' && mode !== 'change') { + throw new Error(`Unknown pointer timing mode: ${mode}`) + } + if (host.__stressPointerActive() !== (mode === 'change')) { + throw new Error(`Pointer ${mode} timing has the wrong initial state.`) + } + let timer + let frame + let listener + const result = new Promise((resolve, reject) => { + const finish = (error, value) => { + host.clearTimeout(timer) + if (frame !== undefined) host.cancelAnimationFrame(frame) + host.document.removeEventListener('pointermove', listener, true) + cancel = undefined + if (error) reject(error) + else resolve(value) + } + cancel = () => finish(new Error('Pointer timing cancelled.')) + // Bound event delivery too, not only the frames after an event arrives. + let receivedEvent = false + timer = host.setTimeout( + () => + finish( + new Error( + receivedEvent + ? `Pointer ${mode} did not settle within 2 seconds.` + : `Pointer ${mode} did not receive pointermove within 2 seconds.`, + ), + ), + 2_000, + ) + listener = (event) => { + receivedEvent = true + const startedAt = host.performance.now() + const poll = () => { + const signature = host.__stressPointerSignature() + if ( + host.__stressPointerActive() && + (mode === 'activate' || + (signature !== undefined && signature !== previousSignature)) + ) { + finish(undefined, { + durationMs: host.performance.now() - startedAt, + trusted: event.isTrusted, + ...(mode === 'change' ? { signature } : {}), + }) + } else { + frame = host.requestAnimationFrame(poll) + } + } + frame = host.requestAnimationFrame(poll) + } + host.document.addEventListener('pointermove', listener, { + capture: true, + once: true, + }) + }) + // Attach rejection handling before returning readiness to Playwright. + pending = result.then( + (value) => ({ value }), + (error) => ({ error: error.message }), + ) + return true + } + host.__stressPointerRead = async () => { + if (!pending) throw new Error('Pointer timing is not armed.') + const owned = pending + try { + const outcome = await owned + if (outcome.error) throw new Error(outcome.error) + return outcome.value + } finally { + if (pending === owned) pending = undefined + } + } + host.__stressPointerCancel = () => { + cancel?.() + pending = undefined + } +} + +export async function measureTrustedPointer( + page, + mode, + target, + previousSignature, +) { + // Await registration, not the eventual sample, before dispatching input. + await page.evaluate( + ({ mode, previousSignature }) => + globalThis.__stressPointerBegin(mode, previousSignature), + { mode, previousSignature }, + ) + try { + await page.mouse.move(target.x, target.y) + return await page.evaluate(() => globalThis.__stressPointerRead()) + } catch (error) { + await page + .evaluate(() => globalThis.__stressPointerCancel()) + .catch(() => {}) + throw error + } +} diff --git a/scripts/benchmark/stress-pointer.test.mjs b/scripts/benchmark/stress-pointer.test.mjs new file mode 100644 index 00000000..26e82a5d --- /dev/null +++ b/scripts/benchmark/stress-pointer.test.mjs @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + installStressPointerTiming, + measureTrustedPointer, +} from './stress-pointer.mjs' + +afterEach(() => vi.useRealTimers()) + +function fixture() { + vi.useFakeTimers() + let active = false, + signature = 'before' + const document = new EventTarget() + const host = { + document, + performance: { now: () => Date.now() }, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => setTimeout(callback, 16), + cancelAnimationFrame: clearTimeout, + __stressPointerActive: () => active, + __stressPointerSignature: () => signature, + } + installStressPointerTiming(host) + return { + host, + document, + activate() { + active = true + }, + change() { + signature = 'after' + }, + } +} + +describe('stress pointer readiness and deadlines', () => { + it('registers before returning readiness and measures from the input event', async () => { + const f = fixture() + expect(f.host.__stressPointerBegin('activate')).toBe(true) + await vi.advanceTimersByTimeAsync(100) + f.activate() + f.document.dispatchEvent(new Event('pointermove')) + const result = f.host.__stressPointerRead() + await vi.advanceTimersByTimeAsync(16) + expect(await result).toEqual({ durationMs: 16, trusted: false }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('fails missing input within two seconds, even before reading the result', async () => { + const f = fixture() + f.host.__stressPointerBegin('activate') + await vi.advanceTimersByTimeAsync(2000) + await expect(f.host.__stressPointerRead()).rejects.toThrow( + 'did not receive pointermove', + ) + expect(vi.getTimerCount()).toBe(0) + }) + + it('fails a received input that never activates and cancels polling', async () => { + const f = fixture() + f.host.__stressPointerBegin('activate') + f.document.dispatchEvent(new Event('pointermove')) + await vi.advanceTimersByTimeAsync(2000) + await expect(f.host.__stressPointerRead()).rejects.toThrow('did not settle') + expect(vi.getTimerCount()).toBe(0) + }) + + it('requires an active changed signature for a sweep', async () => { + const f = fixture() + f.activate() + f.host.__stressPointerBegin('change', 'before') + f.document.dispatchEvent(new Event('pointermove')) + await vi.advanceTimersByTimeAsync(16) + f.change() + const result = f.host.__stressPointerRead() + await vi.advanceTimersByTimeAsync(16) + expect(await result).toEqual({ + durationMs: 32, + trusted: false, + signature: 'after', + }) + }) + + it('rejects invalid initial states and overlapping probes', () => { + const f = fixture() + expect(() => f.host.__stressPointerBegin('change', 'before')).toThrow( + 'initial state', + ) + expect(() => f.host.__stressPointerBegin('unknown')).toThrow('Unknown') + f.host.__stressPointerBegin('activate') + expect(() => f.host.__stressPointerBegin('activate')).toThrow( + 'already armed', + ) + f.host.__stressPointerCancel() + f.activate() + expect(() => f.host.__stressPointerBegin('activate')).toThrow( + 'initial state', + ) + }) + + it('cancels listeners and timers when the chart is cleaned up', async () => { + const f = fixture() + const remove = vi.spyOn(f.document, 'removeEventListener') + f.host.__stressPointerBegin('activate') + f.host.__stressPointerCancel() + expect(remove).toHaveBeenCalledWith( + 'pointermove', + expect.any(Function), + true, + ) + expect(vi.getTimerCount()).toBe(0) + await expect(f.host.__stressPointerRead()).rejects.toThrow('not armed') + }) + + it('does not dispatch input until the browser acknowledges registration', async () => { + let ready + const registration = new Promise((resolve) => { + ready = resolve + }) + const evaluate = vi + .fn() + .mockReturnValueOnce(registration) + .mockResolvedValueOnce({ durationMs: 1 }) + const move = vi.fn().mockResolvedValue(undefined) + const pending = measureTrustedPointer( + { evaluate, mouse: { move } }, + 'activate', + { x: 5, y: 6 }, + ) + expect(move).not.toHaveBeenCalled() + ready(true) + expect(await pending).toEqual({ durationMs: 1 }) + expect(move).toHaveBeenCalledWith(5, 6) + expect(evaluate).toHaveBeenCalledTimes(2) + }) + + it('cancels an armed probe when dispatching input fails', async () => { + const evaluate = vi.fn().mockResolvedValue(true) + const move = vi.fn().mockRejectedValue(new Error('page closed')) + await expect( + measureTrustedPointer({ evaluate, mouse: { move } }, 'activate', { + x: 5, + y: 6, + }), + ).rejects.toThrow('page closed') + expect(evaluate).toHaveBeenCalledTimes(2) + }) +}) diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 0819e2db..303c9483 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -45,6 +45,43 @@ const packageManifest = JSON.parse( ) describe('CI workflow contract', () => { + test('keeps targeted stress diagnostics manual and isolated from normal CI', () => { + const diagnostic = job('stats-diagnostic') + assert.match( + diagnostic, + /if: github\.event_name == 'workflow_dispatch' && inputs\.diagnose_stats/, + ) + assert.match(diagnostic, /timeout-minutes: 5/) + assert.match( + diagnostic, + /--profile=standard --library=tanstack --workload=stats-multi-series-line --diagnostics/, + ) + assert.doesNotMatch( + diagnostic, + /matrix:|benchmark:stress:full|pnpm validate|nx run/, + ) + assert.doesNotMatch(diagnostic, /^\s+needs:/m) + for (const name of ['changes', 'static']) { + assert.match( + job(name), + /if: github\.event_name != 'workflow_dispatch' \|\| !inputs\.diagnose_stats/, + ) + } + for (const name of [ + 'ci', + 'compare-container-canary', + 'bundle-baseline-candidate', + ]) { + assert.match(job(name), /if:.*!inputs\.diagnose_stats/) + } + assert.match( + workflow, + /inputs\.diagnose_stats && '-stats-diagnostic' \|\| ''/, + ) + assert.match(job('stress'), /Run quick stress shard/) + assert.match(diagnostic, /name: Upload phase diagnostics\s+if: always\(\)/) + }) + test('uses least-privilege permissions and public Nx Cloud access', () => { assert.match(workflow, /^permissions:\s*\n\s+contents:\s*read\s*$/m) assert.match(workflow, /^\s+actions:\s*read\s*$/m) diff --git a/scripts/classify-ci-changes.mjs b/scripts/classify-ci-changes.mjs index b30ace4a..01f72a94 100644 --- a/scripts/classify-ci-changes.mjs +++ b/scripts/classify-ci-changes.mjs @@ -34,6 +34,8 @@ const comparisonFiles = new Set([ ]) const stressFiles = new Set([ + 'scripts/benchmark/stress-diagnostics.mjs', + 'scripts/benchmark/stress-pointer.mjs', 'scripts/benchmark/filters.mjs', 'scripts/benchmark/page-errors.mjs', 'scripts/benchmark/result-validity.mjs', diff --git a/scripts/stress-chart-libraries.mjs b/scripts/stress-chart-libraries.mjs index 0a83a3dd..0901b04d 100644 --- a/scripts/stress-chart-libraries.mjs +++ b/scripts/stress-chart-libraries.mjs @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { cpus } from 'node:os' import { resolve } from 'node:path' @@ -8,6 +9,11 @@ import { startBenchmarkServer, } from './benchmark/browser.mjs' import { CellTimeoutError } from './benchmark/cell-timeout.mjs' +import { createStressDiagnostics } from './benchmark/stress-diagnostics.mjs' +import { + installStressPointerTiming, + measureTrustedPointer, +} from './benchmark/stress-pointer.mjs' import { chartLibraries } from './benchmark/chart-libraries.mjs' import { assertKnownFilterValues, @@ -41,6 +47,7 @@ const config = JSON.parse( ) const profileName = optionValue('--profile') ?? 'standard' +const diagnosticMode = process.argv.includes('--diagnostics') const profile = config.profiles[profileName] if (!profile) { throw new Error( @@ -107,6 +114,14 @@ const cases = selectedWorkloads.flatMap((workload) => exportName: `mount${capitalize(workload.chartType)}`, })), ) +const cells = createCells(selectedWorkloads, selectedLibraries, profileName) +if (diagnosticMode && cells.length !== 1) { + throw new Error( + 'Diagnostics require exactly one library, workload, and source count.', + ) +} +if (diagnosticMode) + await writeFile(resolve(resultDirectory, 'stress-diagnostics.jsonl'), '') await buildCases(cases) const browser = await launchBenchmarkBrowser() @@ -115,7 +130,6 @@ const server = await startBenchmarkServer(outputDirectory, { width: 1_400, height: 900, }) -const cells = createCells(selectedWorkloads, selectedLibraries, profileName) const results = [] try { @@ -129,19 +143,21 @@ try { const timing = await runIsolatedWithRetry( browser, 120_000, - (context) => + (context, diagnostics) => runTimingCell( context, server.url, benchmarkCase, cell.sourceCount, profile, + diagnostics, ), cell, 'timing', ) let memory if ( + !diagnosticMode && timing.status === 'ok' && cell.sourceCount === cell.workload.sourceCounts[profileName].at(-1) ) { @@ -212,10 +228,12 @@ const result = { 'A fixed active window advances by five percent through one immutable feed. Stream revisions are frame-paced and individually awaited; burst revisions enqueue synchronously and must drain to one stable final output.', pointer: 'Playwright measures trusted inactive-to-active tooltip activation and active-to-active state changes across adapter-reported data targets.', - memory: - 'Fresh-page CDP JS heap and DOM counters after forced garbage collection; excludes GPU and native canvas allocations.', - retry: - 'An outer timeout or browser-context infrastructure failure receives one immediate fresh-context retry. Renderer, page, protocol, and correctness failures are not retried; every attempted error remains explicit in the result and report.', + memory: diagnosticMode + ? 'Not measured in timing-only diagnostics.' + : 'Fresh-page CDP JS heap and DOM counters after forced garbage collection; excludes GPU and native canvas allocations.', + retry: diagnosticMode + ? 'No automatic retries in diagnostic mode.' + : 'An outer timeout or browser-context infrastructure failure receives one immediate fresh-context retry. Renderer, page, protocol, and correctness failures are not retried; every attempted error remains explicit in the result and report.', output: 'Adapter probes gate rendered dimensions, data items or path vertices, numeric endpoint visibility, and multi-series path, identity, and per-series vertex accounting.', ranking: @@ -226,7 +244,9 @@ const result = { failures, } const markdown = renderMarkdown(result) -const artifactStem = stressArtifactStem(profileName, selectedFilters) +const artifactStem = + stressArtifactStem(profileName, selectedFilters) + + (diagnosticMode ? '--diagnostic' : '') const jsonPath = resolve(resultDirectory, `${artifactStem}.json`) const markdownPath = resolve(resultDirectory, `${artifactStem}.md`) await writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`) @@ -304,6 +324,15 @@ async function runIsolated( let context let stage = 'context' let timeout + const diagnostics = createStressDiagnostics(diagnosticMode, (entry) => { + const record = { id, ...entry } + console.log(JSON.stringify(record)) + appendFileSync( + resolve(resultDirectory, 'stress-diagnostics.jsonl'), + `${JSON.stringify(record)}\n`, + ) + }) + diagnostics.mark('context') try { context = await browserInstance.newContext({ viewport: { width: 1_400, height: 900 }, @@ -312,7 +341,7 @@ async function runIsolated( stage = 'cell' const pageError = contextPageErrorFailure(context) const value = await Promise.race([ - run(context), + run(context, diagnostics), pageError, new Promise((_, reject) => { timeout = setTimeout( @@ -321,7 +350,9 @@ async function runIsolated( ) }), ]) - return value + return diagnosticMode + ? { ...value, diagnostics: diagnostics.snapshot() } + : value } catch (error) { return { id, @@ -332,7 +363,10 @@ async function runIsolated( workloadLabel: workload.label, lane: workload.lane, sourceCount, - error: error instanceof Error ? error.message : String(error), + error: + (error instanceof Error ? error.message : String(error)) + + (diagnosticMode ? ` [phase: ${diagnostics.lastPhase()}]` : ''), + ...(diagnosticMode ? { diagnostics: diagnostics.snapshot() } : {}), retryable: isRetryableCellInfrastructureError(error, stage), } } finally { @@ -359,6 +393,7 @@ async function runIsolatedWithRetry( cell, phase, ) { + if (diagnosticMode) return runIsolated(browserInstance, timeoutMs, run, cell) return retryFailedResult( () => runIsolated(browserInstance, timeoutMs, run, cell), phase, @@ -371,9 +406,17 @@ async function runTimingCell( benchmarkCase, sourceCount, benchmarkProfile, + diagnostics, ) { const page = await context.newPage() + if (diagnosticMode) + page.on('console', (message) => { + const text = message.text() + if (text.startsWith('__chartsStressPhase__:')) + diagnostics.mark(text.slice('__chartsStressPhase__:'.length)) + }) const pageErrors = attachPageErrorCollector(page) + diagnostics.mark('navigation') await page.goto(serverUrl, { waitUntil: 'load' }) const base = await page.evaluate( @@ -385,7 +428,12 @@ async function runTimingCell( sourceCount: count, profile: currentProfile, profileName: selectedProfile, + diagnosticsEnabled, }) => { + const phase = diagnosticsEnabled + ? (name) => console.info(`__chartsStressPhase__:${name}`) + : () => {} + phase('module import') const { mount, createRollingFeed, @@ -397,6 +445,7 @@ async function runTimingCell( prepareStressUpdate, } = await import(moduleUrl) await document.fonts?.ready + phase('source preparation') const width = workload.id === 'dashboard-lines' ? 320 : 800 const height = workload.id === 'dashboard-lines' ? 180 : 400 @@ -460,6 +509,7 @@ async function runTimingCell( longTaskObserver?.observe({ type: 'longtask', buffered: false }) const mountSamples = [] + phase('mount') let output for ( let sampleIndex = 0; @@ -497,6 +547,7 @@ async function runTimingCell( const updates = [] const pointerStateInputs = new Map([['initial', initial.input]]) for (const kind of workload.updates) { + phase(`update:${kind}`) const target = kind === 'roll' ? rollingInputs?.[1] @@ -645,6 +696,7 @@ async function runTimingCell( } let stream + phase('stream') if (workload.stream) { const ring = rollingInputs ? undefined @@ -746,6 +798,7 @@ async function runTimingCell( } let burst + phase('burst') if (workload.burst) { const revisions = currentProfile.burstRevisions const inputs = rollingInputs?.slice(1, revisions + 1) @@ -902,89 +955,8 @@ async function runTimingCell( } throw new Error('Pointer tooltip did not return to an inactive state.') } - globalThis.__stressPointerArm = () => - new Promise((resolve, reject) => { - if (globalThis.__stressPointerActive()) { - reject( - new Error( - 'Pointer activation timing must start from an inactive state.', - ), - ) - return - } - document.addEventListener( - 'pointermove', - (event) => { - const startedAt = performance.now() - const trusted = event.isTrusted - const poll = () => { - if (globalThis.__stressPointerActive()) { - resolve({ - durationMs: performance.now() - startedAt, - trusted, - }) - return - } - if (performance.now() - startedAt >= 2_000) { - reject( - new Error( - 'Pointer tooltip did not activate within 2 seconds.', - ), - ) - return - } - requestAnimationFrame(poll) - } - requestAnimationFrame(poll) - }, - { capture: true, once: true }, - ) - }) - globalThis.__stressPointerArmChange = (previousSignature) => - new Promise((resolve, reject) => { - if (!globalThis.__stressPointerActive()) { - reject( - new Error( - 'Pointer sweep timing must start from an active tooltip.', - ), - ) - return - } - document.addEventListener( - 'pointermove', - (event) => { - const startedAt = performance.now() - const trusted = event.isTrusted - const poll = () => { - const signature = globalThis.__stressPointerSignature() - if ( - globalThis.__stressPointerActive() && - signature !== undefined && - signature !== previousSignature - ) { - resolve({ - durationMs: performance.now() - startedAt, - signature, - trusted, - }) - return - } - if (performance.now() - startedAt >= 2_000) { - reject( - new Error( - 'Pointer tooltip state did not change within 2 seconds.', - ), - ) - return - } - requestAnimationFrame(poll) - } - requestAnimationFrame(poll) - }, - { capture: true, once: true }, - ) - }) globalThis.__stressPointerCleanup = () => { + globalThis.__stressPointerCancel?.() const pointer = globalThis.__stressPointer pointer?.handle.destroy() pointer?.root.element.remove() @@ -1851,11 +1823,13 @@ async function runTimingCell( sourceCount, profile: benchmarkProfile, profileName, + diagnosticsEnabled: diagnosticMode, }, ) let pointer if (benchmarkCase.workload.pointer) { + await page.evaluate(installStressPointerTiming) const initialPointer = await measurePointerState( 'initial', benchmarkProfile.pointerSamples, @@ -1899,6 +1873,7 @@ async function runTimingCell( } async function measurePointerState(state, activationSamples, sweepSamples) { + diagnostics.mark(`pointer:${state}:setup`) await page.evaluate( (pointerState) => globalThis.__stressPointerSetup(pointerState), state, @@ -1914,11 +1889,10 @@ async function runTimingCell( } const samples = [] for (let index = 0; index < activationSamples; index++) { + diagnostics.mark(`pointer:${state}:activate:${index}`) await page.mouse.move(1_200, 800) await page.evaluate(() => globalThis.__stressPointerWaitInactive()) - const pending = page.evaluate(() => globalThis.__stressPointerArm()) - await page.mouse.move(target.x, target.y) - const sample = await pending + const sample = await measureTrustedPointer(page, 'activate', target) const observed = await page.evaluate(() => ({ active: globalThis.__stressPointerActive(), signature: globalThis.__stressPointerSignature(), @@ -1955,6 +1929,7 @@ async function runTimingCell( } const rawSamples = [] for (let index = 0; index < sweepSamples; index++) { + diagnostics.mark(`pointer:${state}:sweep:${index}`) const fraction = (index + 1) / (sweepSamples + 1) const nextTarget = await page.evaluate( (nextFraction) => globalThis.__stressPointerTarget(nextFraction), @@ -1965,12 +1940,12 @@ async function runTimingCell( `${state} pointer sweep target ${index} is unavailable.`, ) } - const pending = page.evaluate( - (signature) => globalThis.__stressPointerArmChange(signature), + const sample = await measureTrustedPointer( + page, + 'change', + nextTarget, previousSignature, ) - await page.mouse.move(nextTarget.x, nextTarget.y) - const sample = await pending const observed = await page.evaluate(() => ({ active: globalThis.__stressPointerActive(), seriesIdentities: globalThis.__stressPointerSeriesIdentities(), @@ -2027,6 +2002,7 @@ async function runTimingCell( } pageErrors.assertNone() + diagnostics.mark('complete') return { ...base, pointer } }