Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f70f1df
feat(replay): replay over time + MP4 export
adibarra May 7, 2026
34fef82
Merge remote-tracking branch 'origin/master' into feat/replay-over-time
adibarra May 13, 2026
86d9a52
feat(replay): move replay launcher into chart export menu as MP4 option
adibarra May 13, 2026
72b7aa9
feat(replay): honor showLineLabels setting in replay/MP4 export
adibarra May 13, 2026
9a14d05
chore: add unicode flag to CSS_VAR_RE regex
adibarra May 13, 2026
7e7703d
refactor(replay): drive ScatterGraph directly so all chart toggles fl…
adibarra May 13, 2026
8cde37a
feat(replay): disable nice() so axes shift continuously during playback
adibarra May 13, 2026
dac7e5a
test(replay): fix selectors after radix layout exposed by refactor
adibarra May 13, 2026
1411521
types(replay): narrow ChartDefinition.chartType to 'e2e' | 'interacti…
adibarra May 13, 2026
a5bbfc1
refactor(replay): replace any-cast on x-axis field with typed accessor
adibarra May 13, 2026
8510ff7
refactor(replay): accept HTMLElement instead of DOM id in MP4 exporter
adibarra May 13, 2026
2f158d4
fix(replay): decouple MP4 export duration from playback speed
adibarra May 13, 2026
4d592ab
feat(replay): honor prefers-reduced-motion with slideshow playback
adibarra May 13, 2026
1253fa0
feat(replay): replace alert with inline error banner and enrich failu…
adibarra May 13, 2026
52027f0
fix(replay): capture encoder errors and bound flush with 30s timeout
adibarra May 13, 2026
041d8c0
chore(replay): strip restate-the-code docblocks and narration comments
adibarra May 13, 2026
3c99145
fix(replay): pause replay rAF on tab visibilitychange to avoid playhe…
adibarra May 13, 2026
d19963e
feat(replay): keyboard nav for scrubber + aria-valuetext announces cu…
adibarra May 13, 2026
063e46a
feat(replay): pre-flight WebCodecs detection and disable Export with …
adibarra May 13, 2026
fc8f3dd
feat(replay): add Cancel button driven by AbortSignal during MP4 export
adibarra May 13, 2026
1a95668
test(replay): add unit suite for replayFrameData pure helpers
adibarra May 13, 2026
e783b54
test(replay): assert animation progresses and parent-chart toggles re…
adibarra May 13, 2026
267913c
chore(replay): drop unused InterpolationResult alias in favor of PerS…
adibarra May 14, 2026
ba5cbb2
feat(replay): switch dialog launcher to imperative-handle ref API
adibarra May 14, 2026
8ffa635
feat(replay): throttle rAF commits via fractionRef + floor dateAtFrac…
adibarra May 14, 2026
4d19aa5
feat(replay): typed Mp4ExportError pipeline with stage attribution, e…
adibarra May 14, 2026
caa1bc5
feat(replay): fix commitFraction ref invariant, humanize export banne…
adibarra May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/app/cypress/component/chart-buttons.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ describe('ChartButtons', () => {
});
});

describe('with MP4 export', () => {
it('shows MP4 option in the export popover and triggers the callback', () => {
const onExportMp4 = cy.stub().as('mp4Export');
const onExportCsv = cy.stub().as('csvExport');
cy.mount(
<div style={{ position: 'relative', width: 400, height: 200 }}>
<div id="test-chart">Chart content</div>
<ChartButtons
chartId="test-chart"
analyticsPrefix="test"
onExportCsv={onExportCsv}
onExportMp4={onExportMp4}
/>
</div>,
);
cy.get('[data-testid="export-button"]').click();
cy.get('[data-testid="export-png-button"]').should('be.visible');
cy.get('[data-testid="export-csv-button"]').should('be.visible');
cy.get('[data-testid="export-mp4-button"]').should('be.visible').click();
cy.get('@mp4Export').should('have.been.calledOnce');
cy.get('@csvExport').should('not.have.been.called');
});

it('shows the popover when only MP4 export is provided (no CSV)', () => {
const onExportMp4 = cy.stub().as('mp4Export');
cy.mount(
<div style={{ position: 'relative', width: 400, height: 200 }}>
<div id="test-chart">Chart content</div>
<ChartButtons chartId="test-chart" analyticsPrefix="test" onExportMp4={onExportMp4} />
</div>,
);
cy.get('[data-testid="export-button"]').click();
cy.get('[data-testid="export-csv-button"]').should('not.exist');
cy.get('[data-testid="export-mp4-button"]').click();
cy.get('@mp4Export').should('have.been.calledOnce');
});
});

describe('hideZoomReset', () => {
it('hides zoom reset button when hideZoomReset is true', () => {
cy.mount(
Expand Down
137 changes: 137 additions & 0 deletions packages/app/cypress/e2e/inference-replay.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const openReplayDialog = () => {
cy.get('[data-testid="chart-figure"]')
.first()
.within(() => {
cy.get('[data-testid="export-button"]').click();
});
cy.get('[data-testid="export-mp4-button"]').first().click();
};

describe('Inference Replay', () => {
before(() => {
cy.window().then((win) => {
win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="inference-chart-display"]').should('exist');
});

it('exposes MP4 export in the chart export menu', () => {
cy.get('[data-testid="chart-figure"]')
.first()
.within(() => {
cy.get('[data-testid="export-button"]').click();
});
cy.get('[data-testid="export-mp4-button"]').should('be.visible');
});

it('opens the replay preview modal from the MP4 menu item', () => {
openReplayDialog();
// Assert the dialog itself is visible. ChartDisplay now opens the launcher
// via an imperative ref; the optional-chain `?.open()` would silently
// no-op if the ref ever failed to attach, so this guards against that.
cy.get('[data-testid="replay-dialog-chart-0"]').should('be.visible');
cy.get('[data-testid="replay-panel-chart-0"]').should('exist');
cy.get('[data-testid="replay-panel-chart-0"]').then(($panel) => {
const text = $panel.text();
const hasControls = $panel.find('[data-testid="replay-play-pause"]').length > 0;
const hasMessage = /Loading benchmark history|Not enough history/u.test(text) || hasControls;
expect(hasMessage).to.equal(true);
});
});

it('exposes scrubber + play/pause + speed controls when history is available', () => {
// Wait for history to resolve into either the controls UI or the empty-state message.
cy.get('[data-testid="replay-panel-chart-0"]', { timeout: 15_000 }).should(($panel) => {
const hasControls = $panel.find('[data-testid="replay-play-pause"]').length > 0;
const hasEmpty = /Not enough history/u.test($panel.text());
expect(hasControls || hasEmpty).to.equal(true);
});

cy.get('[data-testid="replay-panel-chart-0"]').then(($panel) => {
if ($panel.find('[data-testid="replay-play-pause"]').length === 0) {
cy.log('Replay history fixture has < 2 dates; skipping interactive checks');
return;
}
cy.get('[data-testid="replay-scrubber"]').should('exist');
// The speed trigger is always present; individual SelectItems are only
// mounted in the Radix portal while the dropdown is open.
cy.get('[data-testid="replay-speed-select"]').should('exist');
cy.get('[data-testid="replay-export-mp4"]').should('exist');

// Play, then pause, and confirm the button toggles label.
cy.get('[data-testid="replay-play-pause"]').click().should('contain.text', 'Pause');
cy.get('[data-testid="replay-play-pause"]').click().should('contain.text', 'Play');
});
});

it('advances the date overlay and scrubber when Play is pressed', () => {
cy.get('body').then(($body) => {
if ($body.find('[data-testid="replay-play-pause"]').length === 0) {
cy.log('Replay history fixture has < 2 dates; skipping animation check');
return;
}
cy.get('[data-testid="replay-scrubber"]')
.invoke('val')
.then((startVal) => {
cy.get('[data-testid="replay-date-overlay"]')
.invoke('text')
.then((startDate) => {
cy.get('[data-testid="replay-play-pause"]').click();
cy.wait(800);
cy.get('[data-testid="replay-play-pause"]').click();
cy.get('[data-testid="replay-scrubber"]')
.invoke('val')
.should((endVal) => {
expect(Number(endVal)).to.be.greaterThan(Number(startVal));
});
cy.get('[data-testid="replay-date-overlay"]')
.invoke('text')
.should((endDate) => {
expect(endDate).not.to.equal(startDate);
});
});
});
});
});

it('re-renders the replay frame when a parent-chart toggle changes', () => {
cy.get('body').then(($body) => {
if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return;
// Capture the SVG path data for the first roofline as a stable signature.
cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline')
.first()
.invoke('attr', 'd')
.then((beforeD) => {
// Toggle the log-scale setting in the underlying inference context —
// the replay panel shares state with the parent chart, so the chart
// re-renders without us touching the replay UI.
cy.window().then((win) => {
const url = new URL(win.location.href);
const cur = url.searchParams.get('i_log') === '1';
url.searchParams.set('i_log', cur ? '0' : '1');
win.history.replaceState(null, '', url.toString());
// Dispatch a popstate so InferenceContext picks up the change.
win.dispatchEvent(new win.PopStateEvent('popstate'));
});
cy.wait(400);
cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline')
.first()
.invoke('attr', 'd')
.should((afterD) => {
expect(afterD).not.to.equal(beforeD);
});
});
});
});

it('closes the modal', () => {
cy.get('body').then(($body) => {
if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return;
// Radix Dialog closes on Escape — more robust than picking the X by DOM
// order now that the panel contains its own buttons (Play, Reset, …).
cy.get('body').type('{esc}');
cy.get('[data-testid="replay-panel-chart-0"]').should('not.exist');
});
});
});
2 changes: 1 addition & 1 deletion packages/app/cypress/support/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function createMockHardwareConfig(): HardwareConfig {

export function createMockChartDefinition(overrides?: Partial<ChartDefinition>): ChartDefinition {
return {
chartType: 'scatter',
chartType: 'e2e',
heading: 'End-to-End Latency vs Throughput',
x: 'conc' as keyof AggDataEntry,
x_label: 'Concurrency',
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"gray-matter": "^4.0.3",
"iwanthue": "^2.0.0",
"lucide-react": "^1.14.0",
"mp4-muxer": "^5.2.2",
"next": "^16.2.6",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
Expand Down
60 changes: 60 additions & 0 deletions packages/app/src/components/inference/replay/ReplayLauncher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';

import dynamic from 'next/dynamic';
import { forwardRef, useImperativeHandle, useState } from 'react';

import type { ChartDefinition } from '@/components/inference/types';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';

// Keep this in sync with REPLAY_HEIGHT + padding/header/controls in ReplayPanel
// so the dialog doesn't resize as the panel transitions through its loading states.
const REPLAY_PANEL_MIN_HEIGHT = 620;

const ReplayPanel = dynamic(() => import('./ReplayPanel'), {
ssr: false,
loading: () => <Skeleton className="w-full" style={{ height: REPLAY_PANEL_MIN_HEIGHT }} />,
});

interface ReplayLauncherProps {
parentChartId: string;
chartDefinition: ChartDefinition;
yLabel: string;
xLabel: string;
}

export interface ReplayLauncherHandle {
open: () => void;
}

/**
* Owns its own open state so callers only need a ref + .open() call instead of
* a controlled boolean per chart instance. The dialog mounts the panel lazily,
* keeping mp4-muxer and html-to-image out of the main inference bundle.
*/
const ReplayLauncher = forwardRef<ReplayLauncherHandle, ReplayLauncherProps>(
function ReplayLauncher({ parentChartId, chartDefinition, yLabel, xLabel }, ref) {
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-[min(1280px,95vw)] w-[min(1280px,95vw)] max-h-[92vh] overflow-y-auto p-0 sm:rounded-lg"
data-testid={`replay-dialog-${parentChartId}`}
>
<DialogTitle className="sr-only">Replay over time</DialogTitle>
{open && (
<ReplayPanel
parentChartId={parentChartId}
chartDefinition={chartDefinition}
yLabel={yLabel}
xLabel={xLabel}
/>
)}
</DialogContent>
</Dialog>
);
},
);

export default ReplayLauncher;
Loading
Loading