Skip to content

Commit fdc2009

Browse files
authored
Merge pull request #22 from devops-actions/copilot/fix-21
Add comprehensive error handling for invalid file uploads and CSV parsing
2 parents 7d54bfa + d79dac3 commit fdc2009

8 files changed

Lines changed: 1616 additions & 37 deletions

File tree

package-lock.json

Lines changed: 1258 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55
"homepage": "./",
66
"type": "module",
77
"scripts": {
8-
"dev": "vite",
9-
"kill": "fuser -k 5000/tcp",
10-
"build": "tsc -b --noCheck && vite build",
11-
"lint": "eslint .",
12-
"optimize": "vite optimize",
13-
"preview": "vite preview",
14-
"build:pages": "bash ./build-for-gh-pages.sh",
15-
"predeploy": "npm run build:pages",
16-
"deploy": "gh-pages -d dist"
17-
},
8+
"dev": "vite",
9+
"kill": "fuser -k 5000/tcp",
10+
"build": "tsc -b --noCheck && vite build",
11+
"lint": "eslint .",
12+
"test": "vitest",
13+
"test:run": "vitest run",
14+
"optimize": "vite optimize",
15+
"preview": "vite preview",
16+
"build:pages": "bash ./build-for-gh-pages.sh",
17+
"predeploy": "npm run build:pages",
18+
"deploy": "gh-pages -d dist"
19+
},
1820
"dependencies": {
1921
"@github/spark": "^0.0.1",
2022
"@heroicons/react": "^2.2.0",
@@ -79,19 +81,24 @@
7981
"devDependencies": {
8082
"@eslint/js": "^9.21.0",
8183
"@tailwindcss/postcss": "^4.1.8",
84+
"@testing-library/jest-dom": "^6.6.3",
85+
"@testing-library/react": "^16.3.0",
8286
"@types/react": "^19.0.10",
8387
"@types/react-dom": "^19.0.4",
8488
"@vitejs/plugin-react": "^4.3.4",
8589
"@vitejs/plugin-react-swc": "^3.10.2",
90+
"@vitest/ui": "^3.2.4",
8691
"eslint": "^9.28.0",
8792
"eslint-plugin-react-hooks": "^5.2.0",
8893
"eslint-plugin-react-refresh": "^0.4.19",
8994
"gh-pages": "^6.3.0",
9095
"globals": "^16.0.0",
96+
"jsdom": "^26.1.0",
9197
"tailwindcss": "^4.0.17",
9298
"typescript": "~5.7.2",
9399
"typescript-eslint": "^8.25.0",
94-
"vite": "^6.3.5"
100+
"vite": "^6.3.5",
101+
"vitest": "^3.2.4"
95102
},
96103
"workspaces": {
97104
"packages": [

src/App.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useCallback, useRef, DragEvent } from "react";
22
import { Upload, GithubLogo } from "@phosphor-icons/react";
3-
import { toast } from "sonner";
3+
import { toast, Toaster } from "sonner";
44
import {
55
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
66
BarChart, Bar, Cell
@@ -33,11 +33,32 @@ function App() {
3333
const processFile = useCallback((file: File) => {
3434
if (!file) return;
3535

36+
// Add basic file validation
37+
if (!file.type.includes('text') && !file.type.includes('csv') && !file.name.endsWith('.csv')) {
38+
toast.error("Please upload a valid CSV or text file.");
39+
return;
40+
}
41+
3642
const reader = new FileReader();
43+
44+
reader.onerror = () => {
45+
toast.error("Failed to read the file. The file may be corrupted or unreadable.");
46+
};
47+
3748
reader.onload = (e) => {
3849
try {
3950
const csvContent = e.target?.result as string;
40-
if (!csvContent) throw new Error("Failed to read file");
51+
if (!csvContent) throw new Error("Failed to read file content");
52+
53+
// Check if the content looks like text (not binary)
54+
if (csvContent.includes('\0')) {
55+
throw new Error("File appears to be binary. Please upload a text-based CSV file.");
56+
}
57+
58+
// Check for minimum content
59+
if (csvContent.trim().length === 0) {
60+
throw new Error("File is empty. Please upload a CSV file with data.");
61+
}
4162

4263
const parsedData = parseCSV(csvContent);
4364
setData(parsedData);
@@ -60,8 +81,36 @@ function App() {
6081

6182
toast.success(`Loaded ${parsedData.length} records successfully`);
6283
} catch (error) {
63-
console.error("Error parsing CSV:", error);
64-
toast.error("Failed to parse CSV file. Please check the format.");
84+
// Provide user-friendly error messages
85+
let errorMessage = "Failed to parse CSV file. Please check the format.";
86+
87+
if (error instanceof Error) {
88+
// Log detailed error information to console for debugging
89+
console.error("CSV parsing error details:", error);
90+
91+
// Provide more specific error messages based on the error type
92+
if (error.message.includes("missing required columns")) {
93+
errorMessage = "Invalid CSV format: " + error.message;
94+
} else if (error.message.includes("Invalid timestamp")) {
95+
errorMessage = "Invalid data format: " + error.message;
96+
} else if (error.message.includes("Invalid requests used")) {
97+
errorMessage = "Invalid data format: " + error.message;
98+
} else if (error.message.includes("Invalid exceeds quota")) {
99+
errorMessage = "Invalid data format: " + error.message;
100+
} else if (error.message.includes("Invalid CSV row format")) {
101+
errorMessage = "Invalid CSV structure: " + error.message;
102+
} else if (error.message.includes("binary")) {
103+
errorMessage = error.message;
104+
} else if (error.message.includes("empty")) {
105+
errorMessage = error.message;
106+
} else if (error.message.includes("header")) {
107+
errorMessage = "Invalid CSV format: " + error.message;
108+
} else {
109+
errorMessage = error.message;
110+
}
111+
}
112+
113+
toast.error(errorMessage);
65114
setData(null);
66115
setAggregatedData([]);
67116
setModelSummary([]);
@@ -107,10 +156,10 @@ function App() {
107156
const files = e.dataTransfer?.files;
108157
if (files && files.length > 0) {
109158
const file = files[0];
110-
if (file.type === "text/csv" || file.name.endsWith('.csv')) {
159+
if (file.type === "text/csv" || file.name.endsWith('.csv') || file.type.includes('text')) {
111160
processFile(file);
112161
} else {
113-
toast.error("Please upload a CSV file.");
162+
toast.error("Please upload a CSV file. Supported formats: .csv files or text files with CSV content.");
114163
}
115164
}
116165
}, [processFile]);
@@ -493,6 +542,7 @@ function App() {
493542
</div>
494543
</div>
495544
)}
545+
<Toaster position="top-right" />
496546
</div>
497547
);
498548
}

src/lib/utils.ts

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,94 @@ export function parseCSV(csv: string): CopilotUsageData[] {
2727
throw new Error('CSV must contain a header row and at least one data row');
2828
}
2929

30+
// Validate header row
31+
const headerLine = lines[0];
32+
const expectedHeaders = ['Timestamp', 'User', 'Model', 'Requests Used', 'Exceeds Monthly Quota', 'Total Monthly Quota'];
33+
34+
// Parse header row to check for expected columns
35+
// First, trim any trailing whitespace from the header line
36+
const trimmedHeaderLine = headerLine.trim();
37+
const headerMatches = trimmedHeaderLine.match(/("([^"]*)"|([^,]*))(,|$)/g);
38+
if (!headerMatches || headerMatches.length < 6) {
39+
throw new Error('CSV header must contain at least 6 columns');
40+
}
41+
42+
const headers = headerMatches.map(m => {
43+
// Remove trailing comma if present
44+
let processed = m.endsWith(',') ? m.slice(0, -1) : m;
45+
// Remove surrounding quotes if present
46+
processed = processed.replace(/^"(.*)"$/, '$1');
47+
return processed;
48+
}).filter(h => h.trim() !== '').map(h => h.trim()); // Filter empty strings and trim whitespace
49+
50+
// Check if all expected headers are present (case-insensitive exact match)
51+
const missingHeaders = expectedHeaders.filter(expected =>
52+
!headers.some(header => header.toLowerCase() === expected.toLowerCase())
53+
);
54+
55+
// Log detailed header information for debugging
56+
if (missingHeaders.length > 0) {
57+
console.log('CSV Header validation failed:');
58+
console.log('Expected headers:', expectedHeaders);
59+
console.log('Found headers:', headers);
60+
console.log('Missing headers:', missingHeaders);
61+
headers.forEach((header, i) => {
62+
const expectedHeader = expectedHeaders[i];
63+
if (expectedHeader) {
64+
const matches = header.toLowerCase() === expectedHeader.toLowerCase();
65+
console.log(` Column ${i + 1}: "${header}" ${matches ? '✅' : '❌'} (expected: "${expectedHeader}")`);
66+
} else {
67+
console.log(` Column ${i + 1}: "${header}" (extra column)`);
68+
}
69+
});
70+
}
71+
72+
if (missingHeaders.length > 0) {
73+
throw new Error(`CSV is missing required columns: ${missingHeaders.join(', ')}. Expected columns: ${expectedHeaders.join(', ')}`);
74+
}
75+
3076
// Skip the header row and process data rows
31-
return lines.slice(1).map(line => {
32-
// Handle quoted CSV properly
33-
const matches = line.match(/("([^"]*)"|([^,]*))(,|$)/g);
77+
return lines.slice(1).map((line, index) => {
78+
// Handle quoted CSV properly - trim any trailing whitespace first
79+
const trimmedLine = line.trim();
80+
const matches = trimmedLine.match(/("([^"]*)"|([^,]*))(,|$)/g);
3481

3582
if (!matches || matches.length < 6) {
36-
throw new Error('Invalid CSV row format');
83+
throw new Error(`Invalid CSV row format at line ${index + 2}: expected 6 columns, got ${matches ? matches.length : 0}`);
84+
}
85+
86+
const values = matches.map(m => {
87+
// Remove trailing comma if present
88+
let processed = m.endsWith(',') ? m.slice(0, -1) : m;
89+
// Remove surrounding quotes if present
90+
processed = processed.replace(/^"(.*)"$/, '$1');
91+
return processed;
92+
}).filter(v => v.trim() !== ''); // Filter out empty values
93+
94+
// Validate timestamp
95+
const timestamp = new Date(values[0]);
96+
if (isNaN(timestamp.getTime())) {
97+
throw new Error(`Invalid timestamp format at line ${index + 2}: "${values[0]}"`);
3798
}
3899

39-
const values = matches.map(m =>
40-
m.endsWith(',')
41-
? m.slice(0, -1).replace(/^"(.*)"$/, '$1')
42-
: m.replace(/^"(.*)"$/, '$1')
43-
);
100+
// Validate requests used
101+
const requestsUsed = parseFloat(values[3]);
102+
if (isNaN(requestsUsed)) {
103+
throw new Error(`Invalid requests used value at line ${index + 2}: "${values[3]}" must be a number`);
104+
}
105+
106+
// Validate exceeds quota
107+
const exceedsQuotaValue = values[4].toLowerCase();
108+
if (exceedsQuotaValue !== 'true' && exceedsQuotaValue !== 'false') {
109+
throw new Error(`Invalid exceeds quota value at line ${index + 2}: "${values[4]}" must be "true" or "false"`);
110+
}
44111

45112
return {
46-
timestamp: new Date(values[0]),
113+
timestamp,
47114
user: values[1],
48115
model: values[2],
49-
requestsUsed: parseFloat(values[3]),
50-
exceedsQuota: values[4].toLowerCase() === "true",
116+
requestsUsed,
117+
exceedsQuota: exceedsQuotaValue === "true",
51118
totalMonthlyQuota: values[5],
52119
};
53120
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { parseCSV } from '@/lib/utils'
3+
4+
describe('CSV Header Validation', () => {
5+
const requiredHeaders = ['Timestamp', 'User', 'Model', 'Requests Used', 'Exceeds Monthly Quota', 'Total Monthly Quota']
6+
7+
it('should accept CSV with correct headers in exact case', () => {
8+
const csvWithCorrectHeaders = `"Timestamp","User","Model","Requests Used","Exceeds Monthly Quota","Total Monthly Quota"
9+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
10+
11+
expect(() => parseCSV(csvWithCorrectHeaders)).not.toThrow()
12+
})
13+
14+
it('should accept CSV with correct headers in different case', () => {
15+
const csvWithDifferentCase = `"timestamp","user","model","requests used","exceeds monthly quota","total monthly quota"
16+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
17+
18+
expect(() => parseCSV(csvWithDifferentCase)).not.toThrow()
19+
})
20+
21+
it('should accept CSV with extra columns beyond required ones', () => {
22+
const csvWithExtraColumns = `"Timestamp","User","Model","Requests Used","Exceeds Monthly Quota","Total Monthly Quota","Extra Column"
23+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100,extra-data`
24+
25+
expect(() => parseCSV(csvWithExtraColumns)).not.toThrow()
26+
})
27+
28+
it('should reject CSV missing required columns', () => {
29+
const csvMissingColumns = `"Timestamp","User","Model","Requests Used","Wrong Column","Another Column"
30+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
31+
32+
expect(() => parseCSV(csvMissingColumns)).toThrow(
33+
'CSV is missing required columns: Exceeds Monthly Quota, Total Monthly Quota. Expected columns: Timestamp, User, Model, Requests Used, Exceeds Monthly Quota, Total Monthly Quota'
34+
)
35+
})
36+
37+
it('should reject CSV with too few total columns', () => {
38+
const csvTooFewColumns = `"Timestamp","User","Model"
39+
2024-01-01T00:00:00Z,user1,gpt-4`
40+
41+
expect(() => parseCSV(csvTooFewColumns)).toThrow('CSV header must contain at least 6 columns')
42+
})
43+
44+
it('should reject CSV missing specific required columns with detailed error message', () => {
45+
const csvMissingSpecificColumns = `"Timestamp","User","Model","Requests Used","Wrong Column","Total Monthly Quota"
46+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
47+
48+
expect(() => parseCSV(csvMissingSpecificColumns)).toThrow(
49+
'CSV is missing required columns: Exceeds Monthly Quota. Expected columns: Timestamp, User, Model, Requests Used, Exceeds Monthly Quota, Total Monthly Quota'
50+
)
51+
})
52+
53+
it('should validate that headers are present regardless of order (but data parsing expects fixed order)', () => {
54+
// Note: The current implementation validates headers are present but still reads data by fixed positions
55+
// This test verifies that header validation works, but the data parsing might fail if order is wrong
56+
const csvDifferentOrder = `"User","Timestamp","Model","Total Monthly Quota","Requests Used","Exceeds Monthly Quota"
57+
user1,2024-01-01T00:00:00Z,gpt-4,100,1.5,false`
58+
59+
// This should not throw a header validation error (headers are all present)
60+
// But it may throw a data parsing error since data is in wrong order
61+
expect(() => parseCSV(csvDifferentOrder)).toThrow('Invalid timestamp format at line 2')
62+
})
63+
64+
it('should handle CSV with mixed case headers and validate correctly', () => {
65+
const csvMixedCase = `"TIMESTAMP","user","Model","REQUESTS USED","exceeds monthly quota","Total Monthly Quota"
66+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
67+
68+
expect(() => parseCSV(csvMixedCase)).not.toThrow()
69+
})
70+
71+
it('should handle CSV with whitespace in headers', () => {
72+
const csvWithWhitespace = `" Timestamp "," User "," Model "," Requests Used "," Exceeds Monthly Quota "," Total Monthly Quota "
73+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
74+
75+
expect(() => parseCSV(csvWithWhitespace)).not.toThrow()
76+
77+
const result = parseCSV(csvWithWhitespace)
78+
expect(result).toHaveLength(1)
79+
expect(result[0]).toEqual({
80+
timestamp: new Date('2024-01-01T00:00:00Z'),
81+
user: 'user1',
82+
model: 'gpt-4',
83+
requestsUsed: 1.5,
84+
exceedsQuota: false,
85+
totalMonthlyQuota: '100'
86+
})
87+
})
88+
89+
it('should validate data types correctly with valid headers', () => {
90+
const validCsv = `"Timestamp","User","Model","Requests Used","Exceeds Monthly Quota","Total Monthly Quota"
91+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
92+
93+
const result = parseCSV(validCsv)
94+
expect(result).toHaveLength(1)
95+
expect(result[0]).toEqual({
96+
timestamp: new Date('2024-01-01T00:00:00Z'),
97+
user: 'user1',
98+
model: 'gpt-4',
99+
requestsUsed: 1.5,
100+
exceedsQuota: false,
101+
totalMonthlyQuota: '100'
102+
})
103+
})
104+
105+
it('should validate multiple data rows with correct headers', () => {
106+
const csvMultipleRows = `"Timestamp","User","Model","Requests Used","Exceeds Monthly Quota","Total Monthly Quota"
107+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100
108+
2024-01-01T01:00:00Z,user2,gpt-3.5-turbo,2.0,true,50`
109+
110+
const result = parseCSV(csvMultipleRows)
111+
expect(result).toHaveLength(2)
112+
expect(result[1]).toEqual({
113+
timestamp: new Date('2024-01-01T01:00:00Z'),
114+
user: 'user2',
115+
model: 'gpt-3.5-turbo',
116+
requestsUsed: 2.0,
117+
exceedsQuota: true,
118+
totalMonthlyQuota: '50'
119+
})
120+
})
121+
122+
it('should reject CSV with headers that contain required words but are not exact matches', () => {
123+
// This test ensures exact header matching, not substring matching
124+
const csvWithSimilarHeaders = `"Event Timestamp","System User","Model Type","Total Requests Used","User Exceeds Monthly Quota","Total Monthly Quota Limit"
125+
2024-01-01T00:00:00Z,user1,gpt-4,1.5,false,100`
126+
127+
expect(() => parseCSV(csvWithSimilarHeaders)).toThrow(
128+
'CSV is missing required columns: Timestamp, User, Model, Requests Used, Exceeds Monthly Quota, Total Monthly Quota'
129+
)
130+
})
131+
})

0 commit comments

Comments
 (0)