Skip to content
Open
20 changes: 7 additions & 13 deletions public/demo-screen-recording/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@
"studyMetadata": {
"title": "Screen Recording",
"version": "pilot",
"authors": [
"The reVISit Team"
],
"authors": ["The reVISit Team"],
"date": "2026-02-23",
"description": "A simple demo of using screen recording on stimuli that render an external website, where provenance tracking is difficult to implement.",
"organizations": [
"University of Utah",
"WPI"
]
"organizations": ["University of Utah", "WPI"]
},
"uiConfig": {
"contactEmail": "contact@revisit.dev",
Expand All @@ -25,9 +20,7 @@
"recordScreen": true,
"recordScreenFPS": 30
},
"importedLibraries": [
"screen-recording"
],
"importedLibraries": ["screen-recording"],
"components": {
"introduction": {
"type": "markdown",
Expand All @@ -51,15 +44,16 @@
"recordScreen": true,
"clickToRecord": true
},
"barChart_audio_screen": {
"barChart_screen": {
"type": "website",
"nextButtonLocation": "sidebar",
"meta": {
"difficulty": "hard"
},
"description": "Question that asks users to count the number of bars that have a value greater than 1.",
"instruction": "How many bars have a value greater than 1? (Both audio and screen recording are enabled on this page)",
"instruction": "How many bars have a value greater than 1? (Screen recording is enabled on this page; audio recording is disabled)",
"path": "demo-screen-recording/assets/bar-chart.html",
"recordAudio": false,
"response": [
{
"id": "html-response",
Expand All @@ -80,7 +74,7 @@
"$screen-recording.components.screenRecordingPermission",
"external_website_audio",
"external_website_audio_screen",
"barChart_audio_screen"
"barChart_screen"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function ThinkAloudFooter({
}

try {
const url = await storageEngine.getScreenRecording(currentTrial, participantId);
const url = await storageEngine.getScreenRecordingUrl(currentTrial, participantId);
if (!cancelled) {
setScreenRecording({ key: assetKey, url });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ const footerDefaultProps = {

const mockFooterStorageEngine = {
getAudioUrl: vi.fn().mockResolvedValue('http://test/audio.mp3'),
getScreenRecording: vi.fn().mockResolvedValue('http://test/video.mp4'),
getScreenRecordingUrl: vi.fn().mockResolvedValue('http://test/video.mp4'),
saveTags: vi.fn().mockResolvedValue(undefined),
getTags: vi.fn().mockResolvedValue([]),
getAllParticipantAndTaskTags: vi.fn().mockResolvedValue(null),
Expand Down Expand Up @@ -584,7 +584,7 @@ describe('ThinkAloudFooter', () => {
<RealThinkAloudFooter {...footerDefaultProps} storageEngine={makeStorageEngine(mockFooterStorageEngine)} />,
));
expect(mockFooterStorageEngine.getAudioUrl).toHaveBeenCalled();
expect(mockFooterStorageEngine.getScreenRecording).toHaveBeenCalled();
expect(mockFooterStorageEngine.getScreenRecordingUrl).toHaveBeenCalled();
});

test('ignores stale screen recording results after participant changes', async () => {
Expand All @@ -608,7 +608,7 @@ describe('ThinkAloudFooter', () => {
getAudioUrl: vi.fn((_task, participantId) => (
participantId === 'p1' ? audioP1.promise : audioP2.promise
)),
getScreenRecording: vi.fn((_task, participantId) => (
getScreenRecordingUrl: vi.fn((_task, participantId) => (
participantId === 'p1' ? screenP1.promise : screenP2.promise
)),
});
Expand All @@ -628,15 +628,15 @@ describe('ThinkAloudFooter', () => {
const view = render(<RealThinkAloudFooter {...footerDefaultProps} storageEngine={storageEngine} />);
await waitFor(() => expect(storageEngine.getAudioUrl).toHaveBeenCalledWith('trial_0', 'p1'));
audioP1.resolve('audio-p1');
await waitFor(() => expect(storageEngine.getScreenRecording).toHaveBeenCalledWith('trial_0', 'p1'));
await waitFor(() => expect(storageEngine.getScreenRecordingUrl).toHaveBeenCalledWith('trial_0', 'p1'));

selectedParticipantId = 'p2';
view.rerender(<RealThinkAloudFooter {...footerDefaultProps} storageEngine={storageEngine} />);
expect(view.queryByTestId('screen-recording-icon')).toBeNull();

await waitFor(() => expect(storageEngine.getAudioUrl).toHaveBeenCalledWith('trial_0', 'p2'));
audioP2.resolve('audio-p2');
await waitFor(() => expect(storageEngine.getScreenRecording).toHaveBeenCalledWith('trial_0', 'p2'));
await waitFor(() => expect(storageEngine.getScreenRecordingUrl).toHaveBeenCalledWith('trial_0', 'p2'));
screenP2.resolve('screen-p2');
const screenIcon = await waitFor(() => view.getByTestId('screen-recording-icon'));
fireEvent.click(screenIcon.closest('button')!);
Expand Down Expand Up @@ -666,7 +666,7 @@ describe('ThinkAloudFooter', () => {
};
const storageEngine = makeStorageEngine({
getAudioUrl: vi.fn((_task, participantId) => Promise.resolve(`audio-${participantId}`)),
getScreenRecording: vi.fn((_task, participantId) => Promise.resolve(`screen-${participantId}`)),
getScreenRecordingUrl: vi.fn((_task, participantId) => Promise.resolve(`screen-${participantId}`)),
});
vi.mocked(useSearchParams).mockImplementation(() => [
new URLSearchParams(`participantId=${selectedParticipantId}`), setSearchParamsForRace,
Expand Down
38 changes: 30 additions & 8 deletions src/components/audioAnalysis/AudioProvenanceVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@trrack/core';
import WaveSurferType from 'wavesurfer.js';
import { useStorageEngine } from '../../storage/storageEngineHooks';
import { WaveformPeaks } from '../../storage/engines/types';
import { TaskProvenanceTimeline } from './TaskProvenanceTimeline';
import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
import { Timer } from './Timer';
Expand Down Expand Up @@ -308,21 +309,42 @@ export function AudioProvenanceVis({
throw new Error('Participant ID is required to load audio');
}

const [audioUrl, screenUrl] = await Promise.all([
safe(storageEngine.getAudio(taskName, participantId)),
safe(storageEngine.getScreenRecording(taskName, participantId)),
const [audioUrl, cachedPeaks] = await Promise.all([
safe(storageEngine.getAudioUrl(taskName, participantId)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Update the unit mocks and add focused coverage for this new path. The exact head unit suite still fails because ScreenRecordingReplay and ThinkAloudAnalysis test doubles/assertions provide getScreenRecording while production now calls getScreenRecordingUrl. Please migrate those mocks and cover waveform cache hit/miss, save failure, malformed/missing cache, and screen-only behavior so the new replay logic is exercised.

safe(storageEngine.getWaveformPeaks(taskName, participantId)),
]);

const url = screenUrl ?? audioUrl ?? null;

if (!url) {
if (!audioUrl) {
setAnalysisHasAudio(false);
setWaveSurferLoading(false);
wavesurfer.current?.empty();
return;
}

await waveSurfer.load(url!, undefined, duration);
const loadWithoutCachedPeaks = async () => {
waveSurfer.once('ready', () => {
const extractedPeaks: WaveformPeaks = {
peaks: waveSurfer.exportPeaks({ maxLength: 5000, precision: 100 }),
duration: waveSurfer.getDuration(),
};
storageEngine.saveWaveformPeaks(extractedPeaks, taskName, participantId)
.catch((err) => console.warn('Failed to save waveform peaks:', err));
});

await waveSurfer.load(audioUrl, undefined, duration);
};

if (cachedPeaks) {
try {
await waveSurfer.load(audioUrl, cachedPeaks.peaks, cachedPeaks.duration);
} catch (error) {
console.warn('Failed to load cached waveform peaks; decoding audio:', error);
await loadWithoutCachedPeaks();
}
} else {
await loadWithoutCachedPeaks();
}

setWaveSurferLoading(false);

audioRef.current = waveSurfer.getMediaElement();
Expand All @@ -337,7 +359,7 @@ export function AudioProvenanceVis({
setWaveSurferLoading(false);
audioRef.current = null;
updateReplayRef();
throw new Error(error as string);
console.warn('Failed to load audio waveform:', error);
}
} else {
setAnalysisHasAudio(false);
Expand Down
174 changes: 170 additions & 4 deletions src/components/audioAnalysis/tests/AudioProvenanceVis.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import { syncChannel, syncEmitter } from '../../../utils/syncReplay';

// ── mocks ────────────────────────────────────────────────────────────────────

let capturedOnMount: ((ws: unknown) => void | Promise<void>) | undefined;

vi.mock('wavesurfer-react', () => ({
WaveSurfer: vi.fn(({ children }: { children?: ReactNode }) => <div data-testid="wavesurfer">{children}</div>),
WaveSurfer: vi.fn(({ children, onMount }: { children?: ReactNode; onMount?: (ws: unknown) => void | Promise<void> }) => {
capturedOnMount = onMount;
return <div data-testid="wavesurfer">{children}</div>;
}),
WaveForm: () => <div data-testid="waveform" />,
}));

Expand Down Expand Up @@ -50,16 +55,20 @@ vi.mock('react-router', () => ({
},
}));

let mockStorageEngine: Record<string, ReturnType<typeof vi.fn>> | null = null;

vi.mock('../../../storage/storageEngineHooks', () => ({
useStorageEngine: () => ({ storageEngine: null }),
useStorageEngine: () => ({ storageEngine: mockStorageEngine }),
}));

vi.mock('../TaskProvenanceTimeline', () => ({
TaskProvenanceTimeline: () => <div data-testid="provenance-timeline" />,
}));

let mockIsAnalysis = false;

vi.mock('../../../store/hooks/useIsAnalysis', () => ({
useIsAnalysis: () => false,
useIsAnalysis: () => mockIsAnalysis,
}));

vi.mock('../Timer', () => ({
Expand Down Expand Up @@ -159,7 +168,12 @@ function makeNode(

const rootNode = makeNode('root');

beforeEach(() => { vi.clearAllMocks(); });
beforeEach(() => {
vi.clearAllMocks();
mockIsAnalysis = false;
mockStorageEngine = null;
capturedOnMount = undefined;
});
afterEach(() => { cleanup(); });

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down Expand Up @@ -461,3 +475,155 @@ describe('AudioProvenanceVis — syncEmitter listeners', () => {
expect(vi.mocked(syncEmitter.off)).toHaveBeenCalledWith('trialOrder', expect.any(Function));
});
});

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// DOM tests — waveform peaks caching (handleWSMount)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

function makeFakeWaveSurfer(overrides: Record<string, unknown> = {}) {
const listeners: Record<string, (() => void)[]> = {};
return {
load: vi.fn().mockResolvedValue(undefined),
once: vi.fn((event: string, cb: () => void) => {
listeners[event] = [...(listeners[event] || []), cb];
}),
on: vi.fn(),
exportPeaks: vi.fn(() => [[0.5, 0.6]]),
getDuration: vi.fn(() => 12),
getMediaElement: vi.fn(() => ({})),
getWidth: vi.fn(() => 500),
seekTo: vi.fn(),
empty: vi.fn(),
triggerOnce: (event: string) => { (listeners[event] || []).forEach((cb) => cb()); },
...overrides,
};
}

function makeCachingStorageEngine(overrides: Record<string, ReturnType<typeof vi.fn>> = {}) {
return {
getAudioUrl: vi.fn().mockResolvedValue('https://example.com/audio.mp3'),
getWaveformPeaks: vi.fn().mockResolvedValue(null),
saveWaveformPeaks: vi.fn().mockResolvedValue(undefined),
getProvenance: vi.fn().mockResolvedValue(null),
...overrides,
};
}

describe('AudioProvenanceVis — waveform peaks caching', () => {
beforeEach(() => {
mockIsAnalysis = true;
});

test('cache hit: loads audio with cached peaks and skips re-extraction', async () => {
const engine = makeCachingStorageEngine({
getWaveformPeaks: vi.fn().mockResolvedValue({ peaks: [[0.1, 0.2]], duration: 12 }),
});
mockStorageEngine = engine;

await act(async () => render(<AudioProvenanceVis {...defaultProps} answers={answersWithTask} />));

const fakeWs = makeFakeWaveSurfer();
await act(async () => { await capturedOnMount?.(fakeWs); });

expect(fakeWs.load).toHaveBeenCalledWith('https://example.com/audio.mp3', [[0.1, 0.2]], 12);
expect(fakeWs.once).not.toHaveBeenCalled();
expect(engine.saveWaveformPeaks).not.toHaveBeenCalled();
});

test('cache miss: loads audio without peaks then saves extracted peaks once ready', async () => {
const engine = makeCachingStorageEngine();
mockStorageEngine = engine;

await act(async () => render(<AudioProvenanceVis {...defaultProps} answers={answersWithTask} />));

const fakeWs = makeFakeWaveSurfer();
await act(async () => { await capturedOnMount?.(fakeWs); });

expect(fakeWs.load).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined, 0);
expect(fakeWs.once).toHaveBeenCalledWith('ready', expect.any(Function));
expect(engine.saveWaveformPeaks).not.toHaveBeenCalled();

await act(async () => { fakeWs.triggerOnce('ready'); });

expect(engine.saveWaveformPeaks).toHaveBeenCalledWith(
{ peaks: [[0.5, 0.6]], duration: 12 },
'trial_0',
'p1',
);
});

test('save failure after extraction is caught and warned, not thrown', async () => {
const engine = makeCachingStorageEngine({
saveWaveformPeaks: vi.fn().mockRejectedValue(new Error('save failed')),
});
mockStorageEngine = engine;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

await act(async () => render(<AudioProvenanceVis {...defaultProps} answers={answersWithTask} />));

const fakeWs = makeFakeWaveSurfer();
await act(async () => { await capturedOnMount?.(fakeWs); });

await act(async () => {
fakeWs.triggerOnce('ready');
await new Promise((resolve) => { setTimeout(resolve, 0); });
});

expect(warnSpy).toHaveBeenCalledWith('Failed to save waveform peaks:', expect.any(Error));
warnSpy.mockRestore();
});

test('malformed/rejecting cache lookup falls back to a fresh load like a cache miss', async () => {
const engine = makeCachingStorageEngine({
getWaveformPeaks: vi.fn().mockRejectedValue(new Error('corrupt cache')),
});
mockStorageEngine = engine;

await act(async () => render(<AudioProvenanceVis {...defaultProps} answers={answersWithTask} />));

const fakeWs = makeFakeWaveSurfer();
await act(async () => { await capturedOnMount?.(fakeWs); });

expect(fakeWs.load).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined, 0);
});

test('cached waveform load failure retries without peaks and does not reject', async () => {
const engine = makeCachingStorageEngine({
getWaveformPeaks: vi.fn().mockResolvedValue({ peaks: [[0.1, 0.2]], duration: 12 }),
});
mockStorageEngine = engine;
const fakeWs = makeFakeWaveSurfer({
load: vi.fn()
.mockRejectedValueOnce(new Error('cached peaks rejected'))
.mockResolvedValueOnce(undefined),
});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

await act(async () => render(<AudioProvenanceVis {...defaultProps} answers={answersWithTask} />));
await act(async () => { await capturedOnMount?.(fakeWs); });

expect(fakeWs.load).toHaveBeenNthCalledWith(1, 'https://example.com/audio.mp3', [[0.1, 0.2]], 12);
expect(fakeWs.load).toHaveBeenNthCalledWith(2, 'https://example.com/audio.mp3', undefined, 0);
expect(warnSpy).toHaveBeenCalledWith('Failed to load cached waveform peaks; decoding audio:', expect.any(Error));
warnSpy.mockRestore();
});

test('screen-only / no-audio recording does not load the waveform', async () => {
const engine = makeCachingStorageEngine({
getAudioUrl: vi.fn().mockResolvedValue(null),
});
mockStorageEngine = engine;
const setHasAudio = vi.fn();

await act(async () => render(
<AudioProvenanceVis {...defaultProps} answers={answersWithTask} setHasAudio={setHasAudio} />,
));

const fakeWs = makeFakeWaveSurfer();
await act(async () => { await capturedOnMount?.(fakeWs); });

expect(fakeWs.load).not.toHaveBeenCalled();
expect(fakeWs.empty).toHaveBeenCalled();
expect(setHasAudio).toHaveBeenCalledWith(false);
});
});
Loading
Loading