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 reads or processes file contents on the frontend before uploading (e.g., image preview, CSV parsing, client-side validation).

For cases where you need to test frontend logic that processes file contents, use the custom values API to store and restore file data.

  • Development: Up to 20MB per file (set data-is-production-environment="false" on your Meticulous script tag)
  • Production: Up to 1MB per file

Image Preview Before Upload

function ImageUploadWithPreview() {
  const [preview, setPreview] = useState<string>('');

  const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    // Read file contents for preview
    const reader = new FileReader();
    reader.onload = (e) => {
      const dataUrl = e.target?.result as string;
      setPreview(dataUrl);

      // Store for replay
      if (window.Meticulous?.recordCustomValues) {
        window.Meticulous.recordCustomValues({
          imagePreviewData: dataUrl,
        });
      }
    };
    reader.readAsDataURL(file);
  };

  // Restore preview during replay
  useEffect(() => {
    if (window.Meticulous?.isRunningAsTest) {
      const storedData = window.Meticulous.getCustomValues();
      if (storedData?.imagePreviewData) {
        setPreview(storedData.imagePreviewData);
      }
    }
  }, []);

  return (
    <div>
      <input type="file" onChange={handleFileChange} accept="image/*" />
      {preview && <img src={preview} alt="Preview" style={{ maxWidth: '200px' }} />}
      <button onClick={() => uploadImage(preview)}>Upload</button>
    </div>
  );
}

CSV File Processing

function CSVUpload() {
  const [data, setData] = useState<string[][]>([]);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = (e) => {
      const csvText = e.target?.result as string;
      const rows = csvText.split('\n').map(row => row.split(','));
      setData(rows);

      // Store CSV data for replay
      if (window.Meticulous?.recordCustomValues) {
        window.Meticulous.recordCustomValues({
          csvData: csvText,
        });
      }
    };
    reader.readAsText(file);
  };

  // Restore during replay
  useEffect(() => {
    if (window.Meticulous?.isRunningAsTest) {
      const storedData = window.Meticulous.getCustomValues();
      if (storedData?.csvData) {
        const rows = storedData.csvData.split('\n').map((row: string) => row.split(','));
        setData(rows);
      }
    }
  }, []);

  return (
    <div>
      <input type="file" onChange={handleFileChange} accept=".csv" />
      <table>
        {data.map((row, i) => (
          <tr key={i}>
            {row.map((cell, j) => <td key={j}>{cell}</td>)}
          </tr>
        ))}
      </table>
    </div>
  );
}

File Size and Type Validation

function ValidatedFileUpload() {
  const [error, setError] = useState<string>('');

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    setError('');

    // Validate file type
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allowedTypes.includes(file.type)) {
      setError('Only JPEG, PNG, and GIF images are allowed');
      return;
    }

    // Validate file size (5MB max)
    const maxSize = 5 * 1024 * 1024; // 5MB
    if (file.size > maxSize) {
      setError('File must be smaller than 5MB');
      return;
    }

    // Read and store file for replay
    const reader = new FileReader();
    reader.onload = (e) => {
      const dataUrl = e.target?.result as string;

      if (window.Meticulous?.recordCustomValues) {
        window.Meticulous.recordCustomValues({
          uploadedFile: dataUrl,
          fileName: file.name,
          fileType: file.type,
        });
      }

      processFile(dataUrl);
    };
    reader.readAsDataURL(file);
  };

  // Restore during replay
  useEffect(() => {
    if (window.Meticulous?.isRunningAsTest) {
      const stored = window.Meticulous.getCustomValues();
      if (stored?.uploadedFile) {
        processFile(stored.uploadedFile);
      }
    }
  }, []);

  return (
    <div>
      <input type="file" onChange={handleFileChange} />
      {error && <div className="error">{error}</div>}
    </div>
  );
}

Approach 3: Custom Event API (Advanced)

When to use: Complex scenarios where the custom values API isn't sufficient, or you need fine-grained control over replay timing.

Use the custom event API for advanced scenarios. This is more complex but offers maximum flexibility.

function AdvancedFileUpload() {
  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = (e) => {
      const fileData = e.target?.result;

      // Record custom event
      if (window.Meticulous?.recordCustomEvent) {
        window.Meticulous.recordCustomEvent({
          type: 'FILE_UPLOADED',
          payload: {
            fileName: file.name,
            fileType: file.type,
            fileData: fileData,
          },
        });
      }
    };
    reader.readAsDataURL(file);
  };

  useEffect(() => {
    // Listen for replay events
    if (window.Meticulous?.onReplayCustomEvent) {
      window.Meticulous.onReplayCustomEvent((event) => {
        if (event.type === 'FILE_UPLOADED') {
          processUploadedFile(event.payload);
        }
      });
    }
  }, []);

  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;

      // Check size limit for custom values API
      const maxChars = window.Meticulous?.isProductionEnvironment ? 1_000_000 : 20_000_000;

      if (dataUrl.length > maxChars) {
        console.warn('File too large to store in custom values, skipping preview in tests');
        // Fall back to Approach 1: just skip validation
      } else {
        window.Meticulous?.recordCustomValues({ fileData: dataUrl });
      }

      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
  • Files are small enough (under 1-20MB)
  • You need to display file previews or parse file contents

Use Approach 3 (custom event API) for:

  • Complex scenarios requiring fine-grained control
  • Advanced timing or state management needs
  • Integration with existing custom event systems

For more details on the Meticulous API, see the window.Meticulous object documentation.