AI agents: read this page as markdown at /docs/how-to/handle-file-uploads.md, or start from the full AI-readable index at /llms.txt.

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.


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:

  1. During recording: Real file uploaded → Real backend response captured → Response includes file metadata/URL
  2. 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.