fix: unify theme system - fix theme switching persistence

- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
This commit is contained in:
2026-01-18 22:33:41 +01:00
parent ef60dafd73
commit ddb67ba9e5
306 changed files with 59580 additions and 6063 deletions

View File

@@ -22,6 +22,16 @@ The `file-utils` module provides:
- **Validation helpers**: Row count, header checks, content validation
- **Format support**: Multiple sheet support (XLSX), text extraction (PDF), archive extraction (ZIP)
## Why Use This Instead of Vanilla Playwright?
| Vanilla Playwright | File Utils |
| ------------------------------------------- | ------------------------------------------------ |
| ~80 lines per CSV flow (download + parse) | ~10 lines end-to-end |
| Manual event orchestration for downloads | Encapsulated in `handleDownload()` |
| Manual path handling and `saveAs` | Returns a ready-to-use file path |
| Manual existence checks and error handling | Centralized in one place via utility patterns |
| Manual CSV parsing config (headers, typing) | `readCSV()` returns `{ data, headers }` directly |
## Pattern Examples
### Example 1: UI-Triggered CSV Download
@@ -40,20 +50,18 @@ test('should download and validate CSV', async ({ page }) => {
const downloadPath = await handleDownload({
page,
downloadDir: DOWNLOAD_DIR,
trigger: () => page.click('[data-testid="export-csv"]'),
trigger: () => page.getByTestId('download-button-text/csv').click(),
});
const { content } = await readCSV({ filePath: downloadPath });
const csvResult = await readCSV({ filePath: downloadPath });
// Validate headers
expect(content.headers).toEqual(['ID', 'Name', 'Email', 'Role']);
// Validate data
expect(content.data).toHaveLength(10);
expect(content.data[0]).toMatchObject({
// Access parsed data and headers
const { data, headers } = csvResult.content;
expect(headers).toEqual(['ID', 'Name', 'Email']);
expect(data[0]).toMatchObject({
ID: expect.any(String),
Name: expect.any(String),
Email: expect.stringMatching(/@/),
Email: expect.any(String),
});
});
```
@@ -81,25 +89,27 @@ test('should read multi-sheet XLSX', async () => {
trigger: () => page.click('[data-testid="export-xlsx"]'),
});
const { content } = await readXLSX({ filePath: downloadPath });
const xlsxResult = await readXLSX({ filePath: downloadPath });
// Access specific sheets
const summarySheet = content.sheets.find((s) => s.name === 'Summary');
const detailsSheet = content.sheets.find((s) => s.name === 'Details');
// Verify worksheet structure
expect(xlsxResult.content.worksheets.length).toBeGreaterThan(0);
const worksheet = xlsxResult.content.worksheets[0];
expect(worksheet).toBeDefined();
expect(worksheet).toHaveProperty('name');
// Validate summary
expect(summarySheet.data).toHaveLength(1);
expect(summarySheet.data[0].TotalRecords).toBe('150');
// Access sheet data
const sheetData = worksheet?.data;
expect(Array.isArray(sheetData)).toBe(true);
// Validate details
expect(detailsSheet.data).toHaveLength(150);
expect(detailsSheet.headers).toContain('TransactionID');
// Use type assertion for type safety
const firstRow = sheetData![0] as Record<string, unknown>;
expect(firstRow).toHaveProperty('id');
});
```
**Key Points**:
- `sheets` array with `name` and `data` properties
- `worksheets` array with `name` and `data` properties
- Access sheets by name
- Each sheet has its own headers and data
- Type-safe sheet iteration
@@ -117,26 +127,48 @@ test('should validate PDF report', async () => {
const downloadPath = await handleDownload({
page,
downloadDir: DOWNLOAD_DIR,
trigger: () => page.click('[data-testid="download-report"]'),
trigger: () => page.getByTestId('download-button-Text-based PDF Document').click(),
});
const { content } = await readPDF({ filePath: downloadPath });
const pdfResult = await readPDF({ filePath: downloadPath });
// content.text is extracted text from all pages
expect(content.text).toContain('Financial Report Q4 2024');
expect(content.text).toContain('Total Revenue:');
// Validate page count
expect(content.numpages).toBeGreaterThan(10);
// content is extracted text from all pages
expect(pdfResult.pagesCount).toBe(1);
expect(pdfResult.fileName).toContain('.pdf');
expect(pdfResult.content).toContain('All you need is the free Adobe Acrobat Reader');
});
```
**Key Points**:
**PDF Reader Options:**
- `content.text` contains all extracted text
- `content.numpages` for page count
- PDF parsing handles multi-page documents
- Search for specific phrases
```typescript
const result = await readPDF({
filePath: '/path/to/document.pdf',
mergePages: false, // Keep pages separate (default: true)
debug: true, // Enable debug logging
maxPages: 10, // Limit processing to first 10 pages
});
```
**Important Limitation - Vector-based PDFs:**
Text extraction may fail for PDFs that store text as vector graphics (e.g., those generated by jsPDF):
```typescript
// Vector-based PDF example (extraction fails gracefully)
const pdfResult = await readPDF({ filePath: downloadPath });
expect(pdfResult.pagesCount).toBe(1);
expect(pdfResult.info.extractionNotes).toContain(
'Text extraction from vector-based PDFs is not supported.'
);
```
Such PDFs will have:
- `textExtractionSuccess: false`
- `isVectorBased: true`
- Explanatory message in `extractionNotes`
### Example 4: ZIP Archive Validation
@@ -154,25 +186,33 @@ test('should validate ZIP archive', async () => {
trigger: () => page.click('[data-testid="download-backup"]'),
});
const { content } = await readZIP({ filePath: downloadPath });
const zipResult = await readZIP({ filePath: downloadPath });
// Check file list
expect(content.files).toContain('data.csv');
expect(content.files).toContain('config.json');
expect(content.files).toContain('readme.txt');
expect(Array.isArray(zipResult.content.entries)).toBe(true);
expect(zipResult.content.entries).toContain(
'Case_53125_10-19-22_AM/Case_53125_10-19-22_AM_case_data.csv'
);
// Read specific file from archive
const configContent = content.zip.readAsText('config.json');
const config = JSON.parse(configContent);
// Extract specific file
const targetFile = 'Case_53125_10-19-22_AM/Case_53125_10-19-22_AM_case_data.csv';
const zipWithExtraction = await readZIP({
filePath: downloadPath,
fileToExtract: targetFile,
});
expect(config.version).toBe('2.0');
// Access extracted file buffer
const extractedFiles = zipWithExtraction.content.extractedFiles || {};
const fileBuffer = extractedFiles[targetFile];
expect(fileBuffer).toBeInstanceOf(Buffer);
expect(fileBuffer?.length).toBeGreaterThan(0);
});
```
**Key Points**:
- `content.files` lists all files in archive
- `content.zip.readAsText()` extracts specific files
- `content.entries` lists all files in archive
- `fileToExtract` extracts specific files to Buffer
- Validate archive structure
- Read and parse individual files from ZIP
@@ -185,7 +225,7 @@ test('should validate ZIP archive', async () => {
```typescript
test('should download via API', async ({ page, request }) => {
const downloadPath = await handleDownload({
page,
page, // Still need page for download events
downloadDir: DOWNLOAD_DIR,
trigger: async () => {
const response = await request.get('/api/export/csv', {
@@ -211,20 +251,123 @@ test('should download via API', async ({ page, request }) => {
- Still need `page` for download events
- Works with authenticated endpoints
## Validation Helpers
### Example 6: Reading CSV from Buffer (ZIP extraction)
**Context**: Read CSV content directly from a Buffer (e.g., extracted from ZIP).
**Implementation**:
```typescript
// CSV validation
const { isValid, errors } = await validateCSV({
filePath: downloadPath,
expectedRowCount: 10,
requiredHeaders: ['ID', 'Name', 'Email'],
// Read from a Buffer (e.g., extracted from a ZIP)
const zipResult = await readZIP({
filePath: 'archive.zip',
fileToExtract: 'data.csv',
});
const fileBuffer = zipResult.content.extractedFiles?.['data.csv'];
const csvFromBuffer = await readCSV({ content: fileBuffer });
expect(isValid).toBe(true);
expect(errors).toHaveLength(0);
// Read from a string
const csvString = 'name,age\nJohn,30\nJane,25';
const csvFromString = await readCSV({ content: csvString });
const { data, headers } = csvFromString.content;
expect(headers).toContain('name');
expect(headers).toContain('age');
```
## API Reference
### CSV Reader Options
| Option | Type | Default | Description |
| -------------- | ------------------ | -------- | -------------------------------------- |
| `filePath` | `string` | - | Path to CSV file (mutually exclusive) |
| `content` | `string \| Buffer` | - | Direct content (mutually exclusive) |
| `delimiter` | `string \| 'auto'` | `','` | Value separator, auto-detect if 'auto' |
| `encoding` | `string` | `'utf8'` | File encoding |
| `parseHeaders` | `boolean` | `true` | Use first row as headers |
| `trim` | `boolean` | `true` | Trim whitespace from values |
### XLSX Reader Options
| Option | Type | Description |
| ----------- | -------- | ------------------------------ |
| `filePath` | `string` | Path to XLSX file |
| `sheetName` | `string` | Name of sheet to set as active |
### PDF Reader Options
| Option | Type | Default | Description |
| ------------ | --------- | ------- | --------------------------- |
| `filePath` | `string` | - | Path to PDF file (required) |
| `mergePages` | `boolean` | `true` | Merge text from all pages |
| `maxPages` | `number` | - | Maximum pages to extract |
| `debug` | `boolean` | `false` | Enable debug logging |
### ZIP Reader Options
| Option | Type | Description |
| --------------- | -------- | ---------------------------------- |
| `filePath` | `string` | Path to ZIP file |
| `fileToExtract` | `string` | Specific file to extract to Buffer |
### Return Values
#### CSV Reader Return Value
```typescript
{
content: {
data: Array<Array<string | number>>, // Parsed rows (excludes header row if parseHeaders: true)
headers: string[] | null // Column headers (null if parseHeaders: false)
}
}
```
#### XLSX Reader Return Value
```typescript
{
content: {
worksheets: Array<{
name: string, // Sheet name
rows: Array<Array<any>>, // All rows including headers
headers?: string[] // First row as headers (if present)
}>
}
}
```
#### PDF Reader Return Value
```typescript
{
content: string, // Extracted text (merged or per-page based on mergePages)
pagesCount: number, // Total pages in PDF
fileName?: string, // Original filename if available
info?: Record<string, any> // PDF metadata (author, title, etc.)
}
```
> **Note**: When `mergePages: false`, `content` is an array of strings (one per page). When `maxPages` is set, only that many pages are extracted.
#### ZIP Reader Return Value
```typescript
{
content: {
entries: Array<{
name: string, // File/directory path within ZIP
size: number, // Uncompressed size in bytes
isDirectory: boolean // True for directories
}>,
extractedFiles: Record<string, Buffer | string> // Extracted file contents by path
}
}
```
> **Note**: When `fileToExtract` is specified, only that file appears in `extractedFiles`.
## Download Cleanup Pattern
```typescript
@@ -234,6 +377,66 @@ test.afterEach(async () => {
});
```
## Comparison with Vanilla Playwright
Vanilla Playwright (real test) snippet:
```typescript
// ~80 lines of boilerplate!
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByTestId('download-button-CSV Export').click(),
]);
const failure = await download.failure();
expect(failure).toBeNull();
const filePath = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(filePath);
await expect
.poll(
async () => {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
},
{ timeout: 5000, intervals: [100, 200, 500] }
)
.toBe(true);
const csvContent = await fs.readFile(filePath, 'utf-8');
const parseResult = parse(csvContent, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
transformHeader: (header: string) => header.trim(),
});
if (parseResult.errors.length > 0) {
throw new Error(`CSV parsing errors: ${JSON.stringify(parseResult.errors)}`);
}
const data = parseResult.data as Array<Record<string, unknown>>;
const headers = parseResult.meta.fields || [];
```
With File Utils, the same flow becomes:
```typescript
const downloadPath = await handleDownload({
page,
downloadDir: DOWNLOAD_DIR,
trigger: () => page.getByTestId('download-button-text/csv').click(),
});
const { data, headers } = (await readCSV({ filePath: downloadPath })).content;
```
## Related Fragments
- `overview.md` - Installation and imports
@@ -242,7 +445,7 @@ test.afterEach(async () => {
## Anti-Patterns
**❌ Not cleaning up downloads:**
**DON'T leave downloads in place:**
```typescript
test('creates file', async () => {
@@ -251,7 +454,7 @@ test('creates file', async () => {
})
```
**✅ Clean up after tests:**
**DO clean up after tests:**
```typescript
test.afterEach(async () => {