Handle file uploads
Overview
By default, Meticulous does not automatically store files that users upload during recording. This design decision is intentional:
- Storage efficiency: Recording file contents would significantly increase storage costs
- Privacy: Avoiding file storage prevents capturing potentially sensitive user data
- Performance: File uploads can be large and would slow down session recording
- Practicality: Most apps only need to test the upload flow, not the file contents themselves
When a user uploads a file during a recorded session (via HTML file input or drag-and-drop), Meticulous records the user interaction but not the file itself. During replay, the file won't be attached to the input element.
This guide shows you how to handle file uploads in your Meticulous tests using three different approaches.
Approach 1: Skip Validation (Recommended)
When to use: Most cases where your app validates that a file is present before proceeding.
The recommended approach is to bypass file validation during test runs. Since Meticulous stubs out network requests, your backend will respond with mocked data as if the file was successfully uploaded, allowing you to test the complete user flow.
Basic Example
const handleClickUpload = () => {
if (window.Meticulous?.isRunningAsTest) {
// Skip validation during Meticulous tests
goToNextStage();
} else if (fileInput.files.length > 0) {
goToNextStage();
} else {
showIsRequiredError();
}
}
Single File Input with Validation
function ProfilePictureUpload() {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string>('');
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setError('');
}
};
const handleSubmit = async () => {
// Skip file validation during Meticulous tests
if (!window.Meticulous?.isRunningAsTest && !file) {
setError('Please select a file');
return;
}
// During tests, formData will be empty, but the mocked backend response
// will return as if the file was successfully uploaded
const formData = new FormData();
if (file) {
formData.append('profilePicture', file);
}
const response = await fetch('/api/upload-profile-picture', {
method: 'POST',
body: formData,
});
const data = await response.json();
// data.imageUrl will be the mocked value during tests
showSuccessMessage(`Uploaded: ${data.imageUrl}`);
};
return (
<div>
<input type="file" onChange={handleFileChange} accept="image/*" />
{error && <span className="error">{error}</span>}
<button onClick={handleSubmit}>Upload</button>
</div>
);
}
Multiple File Inputs
function DocumentUploadForm() {
const [resume, setResume] = useState<File | null>(null);
const [coverLetter, setCoverLetter] = useState<File | null>(null);
const handleSubmit = async () => {
// Validate files only when not running as a test
if (!window.Meticulous?.isRunningAsTest) {
if (!resume) {
alert('Resume is required');
return;
}
if (!coverLetter) {
alert('Cover letter is required');
return;
}
}
const formData = new FormData();
if (resume) formData.append('resume', resume);
if (coverLetter) formData.append('coverLetter', coverLetter);
await fetch('/api/submit-application', {
method: 'POST',
body: formData,
});
// Backend response is mocked during tests, so this will work
navigateToConfirmationPage();
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div>
<label>Resume (Required)</label>
<input
type="file"
onChange={(e) => setResume(e.target.files?.[0] || null)}
accept=".pdf,.doc,.docx"
/>
</div>
<div>
<label>Cover Letter (Required)</label>
<input
type="file"
onChange={(e) => setCoverLetter(e.target.files?.[0] || null)}
accept=".pdf,.doc,.docx"
/>
</div>
<button type="submit">Submit Application</button>
</form>
);
}
Drag-and-Drop Upload
function DragDropUpload() {
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
setFile(droppedFile);
}
};
const handleUpload = async () => {
// Skip validation during tests
if (!window.Meticulous?.isRunningAsTest && !file) {
alert('Please drop a file first');
return;
}
const formData = new FormData();
if (file) {
formData.append('file', file);
}
await fetch('/api/upload', { method: 'POST', body: formData });
showSuccessMessage();
};
return (
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
className={isDragging ? 'dragging' : ''}
>
{file ? `Selected: ${file.name}` : 'Drop file here'}
<button onClick={handleUpload}>Upload</button>
</div>
);
}
Why This Works
Since Meticulous stubs out network responses from your backend, requests like POST /api/upload or GET /api/file/123 will replay with the exact responses that were captured during recording. This means:
- During recording: Real file uploaded → Real backend response captured → Response includes file metadata/URL
- During replay: No file uploaded → Mocked backend response → Same response as recording, so app behaves identically
You get complete test coverage of the user flow without needing the actual file contents.
Approach 2: Store File Contents (For Frontend Processing)
When to use: Your app needs a saved value at a known point during replay, and does not need to reproduce the timing of each file selection.
Use the custom values API to store strings with window.Meticulous.record.recordCustomData(key, value) and read them during replay with window.Meticulous.replay.retrieveCustomData(key).
Both keys and values must be strings. Serialize objects with JSON.stringify before recording and parse them after retrieval. Recording the same key again overwrites its previous value; retrieval returns null if the key was not recorded.
Custom values do not replay file-selection events or populate input.files. Restoring a preview in a mount effect can display it before the user originally selected a file, and only the last value for a key is retained. For image previews, CSV processing, or repeated selections that must happen at the recorded time, use Approach 3 below.
The default limits for a custom value in the initialized recorder are 1,000,000 characters in production, 3,000,000 when the environment is unknown, and 20,000,000 in non-production. These limits apply to the stored string, not the original file size; base64 data URLs are larger than the original file. Recorder configuration can override the defaults. Check the returned { success } result, as shown under Error Handling below.
Storing an Image or Text File
Call this helper from your existing file-selection handler. It records the contents after reading the file:
async function recordFileContents(file: File) {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const meticulous = window.Meticulous;
if (meticulous && !meticulous.isRunningAsTest) {
return meticulous.record.recordCustomData('imagePreviewData', dataUrl);
}
}
At the point where your application needs the saved data during replay:
if (window.Meticulous?.isRunningAsTest) {
const dataUrl = window.Meticulous.replay.retrieveCustomData('imagePreviewData');
if (dataUrl !== null) {
processFile(dataUrl);
}
}
For text such as CSV content, store the text directly instead of a data URL:
const meticulous = window.Meticulous;
if (meticulous && !meticulous.isRunningAsTest) {
meticulous.record.recordCustomData('csvData', csvText);
}
if (meticulous?.isRunningAsTest) {
const csvText = meticulous.replay.retrieveCustomData('csvData');
if (csvText !== null) {
processCsv(csvText);
}
}
Storing File Metadata with Contents
Serialize the data and metadata into one string:
const meticulous = window.Meticulous;
if (meticulous && !meticulous.isRunningAsTest) {
meticulous.record.recordCustomData('uploadedFile', JSON.stringify({
dataUrl,
fileName: file.name,
fileType: file.type,
}));
}
if (meticulous?.isRunningAsTest) {
const serializedFile = meticulous.replay.retrieveCustomData('uploadedFile');
if (serializedFile !== null) {
const storedFile = JSON.parse(serializedFile);
processFile(storedFile.dataUrl);
}
}
Approach 3: Custom Event API (Advanced)
When to use: Your app displays a preview or processes file contents when a file is selected, including when users select multiple files in succession.
Use the custom event API to record each completed file read and deliver its data at the recorded time during replay. Record with record.recordCustomEvent(type, serializedData) and subscribe with replay.addCustomEventListener(type, callback). The callback receives the serialized string.
Register the listener before the recorded event occurs. In this React example, the effect ignores callbacks after cleanup because the API does not provide a listener-removal method. Use an event type specific to this upload flow so other upload components do not process the same events.
function AdvancedFileUpload() {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (window.Meticulous?.isRunningAsTest) return;
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const payload = {
fileName: file.name,
fileType: file.type,
fileData: e.target?.result as string,
};
const meticulous = window.Meticulous;
if (meticulous && !meticulous.isRunningAsTest) {
const result = meticulous.record.recordCustomEvent(
'PROFILE_IMAGE_READ',
JSON.stringify(payload),
);
if (!result.success) {
console.warn('File contents could not be recorded for replay');
}
}
// Run the same application logic during recording and replay
processUploadedFile(payload);
};
reader.readAsDataURL(file);
};
useEffect(() => {
if (!window.Meticulous?.isRunningAsTest) return;
let active = true;
window.Meticulous.replay.addCustomEventListener(
'PROFILE_IMAGE_READ',
(serializedData) => {
if (active) {
return processUploadedFile(JSON.parse(serializedData));
}
},
);
return () => { active = false; };
}, []);
return <input type="file" onChange={handleFileChange} />;
}
Complete Integration Examples
With FormData and Fetch
async function uploadFile(file: File | null) {
// Skip file validation during tests
if (!window.Meticulous?.isRunningAsTest && !file) {
throw new Error('No file selected');
}
const formData = new FormData();
if (file) {
formData.append('file', file);
formData.append('userId', getCurrentUserId());
formData.append('uploadType', 'document');
}
const response = await fetch('/api/v1/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getAuthToken()}`,
},
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
// Backend response is mocked during replay
const result = await response.json();
return result.fileUrl;
}
With XMLHttpRequest Progress Tracking
function uploadWithProgress(file: File | null, onProgress: (percent: number) => void) {
return new Promise((resolve, reject) => {
// Skip validation during tests
if (!window.Meticulous?.isRunningAsTest && !file) {
reject(new Error('No file selected'));
return;
}
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
onProgress(percentComplete);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('Upload failed'));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.open('POST', '/api/upload');
const formData = new FormData();
if (file) {
formData.append('file', file);
}
xhr.send(formData);
});
}
Common Patterns
Multiple Files from Single Input
function MultiFileUpload() {
const [files, setFiles] = useState<File[]>([]);
const handleFilesChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || []);
setFiles(selectedFiles);
};
const handleUpload = async () => {
// Skip validation during tests
if (!window.Meticulous?.isRunningAsTest && files.length === 0) {
alert('Please select at least one file');
return;
}
const formData = new FormData();
files.forEach((file, index) => {
formData.append(`file${index}`, file);
});
await fetch('/api/upload-multiple', {
method: 'POST',
body: formData,
});
};
return (
<div>
<input type="file" multiple onChange={handleFilesChange} />
<p>{files.length} files selected</p>
<button onClick={handleUpload}>Upload All</button>
</div>
);
}
Conditional File Processing
function ConditionalUpload() {
const [shouldValidate, setShouldValidate] = useState(false);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (shouldValidate && !window.Meticulous?.isRunningAsTest) {
// Only process file if validation is enabled and not in test
const reader = new FileReader();
reader.onload = (e) => {
validateFileContents(e.target?.result);
};
reader.readAsText(file);
} else {
// Skip to upload
uploadFile(file);
}
};
return (
<div>
<label>
<input
type="checkbox"
checked={shouldValidate}
onChange={(e) => setShouldValidate(e.target.checked)}
/>
Validate file contents before upload
</label>
<input type="file" onChange={handleFileChange} />
</div>
);
}
Error Handling
File Too Large for Custom Values API
function SmartFileUpload() {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
const meticulous = window.Meticulous;
if (meticulous && !meticulous.isRunningAsTest) {
const result = meticulous.record.recordCustomData('fileData', dataUrl);
if (!result.success) {
console.warn('File contents could not be recorded for replay');
// Handle missing replay data in your application.
}
}
processFile(dataUrl);
};
reader.readAsDataURL(file);
};
return <input type="file" onChange={handleFileChange} />;
}
Handling Unsupported File Types
function TypeSafeUpload() {
const ALLOWED_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'application/pdf': ['.pdf'],
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!window.Meticulous?.isRunningAsTest) {
if (!Object.keys(ALLOWED_TYPES).includes(file.type)) {
alert(`Unsupported file type: ${file.type}`);
event.target.value = ''; // Clear input
return;
}
}
uploadFile(file);
};
const acceptString = Object.values(ALLOWED_TYPES).flat().join(',');
return <input type="file" accept={acceptString} onChange={handleFileChange} />;
}
Summary
Most apps should use Approach 1 (skip validation during tests) because:
- Simple and requires minimal code changes
- Works with Meticulous' network stubbing
- Tests the complete user flow including backend responses
- No file size limitations
Use Approach 2 (store file contents) only when:
- You need to test frontend file processing logic
- The serialized contents fit within the recorder's configured limit
- Your app consumes the saved value at a known point during replay
Use Approach 3 (custom event API) for:
- Image previews or file processing that must run at the recorded time
- Repeated file selections whose individual contents must be preserved
- Integration with existing custom event systems
For more details on the Meticulous API, see the window.Meticulous object documentation.