Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: dev serve
.PHONY: dev serve test test-report

# Start a local development server on port 3000
dev:
Expand All @@ -9,3 +9,13 @@ dev:
serve:
@echo "Starting dev server at http://localhost:3000"
@python3 -m http.server 3000

# Run Playwright tests
test:
@echo "Running Playwright tests..."
@npx playwright test

# Show Playwright test report
test-report:
@echo "Opening test report..."
@npx playwright show-report
8 changes: 4 additions & 4 deletions src/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ function generateBill() {
function loadFromHistory(id) {
const entry = getHistoryEntry(id);
if (!entry || !entry.data) return;
isApplyingData = true;
applyDataToForm(entry.data, (options) => {
isApplyingData = true;
syncFromForm(options);
isApplyingData = false;
});
isApplyingData = false;
toggleHistoryPanel(false);
}

Expand Down Expand Up @@ -148,11 +148,11 @@ function attachEventHandlers() {
alert('Paste invoice JSON first.');
return;
}
isApplyingData = true;
importJsonData(raw, (options) => {
isApplyingData = true;
syncFromForm(options);
isApplyingData = false;
});
isApplyingData = false;
});

document.getElementById('importFile').addEventListener('change', (event) => {
Expand Down
4 changes: 2 additions & 2 deletions src/js/modules/json-io.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export function importJsonData(rawJson, syncCallback) {

const base = getDataFromForm();
const merged = deepMerge(base, parsed);
merged.meta = merged.meta || {};
merged.meta.showInvoice = true;

// Update display if needed
// Apply core data
applyDataToForm(merged, syncCallback);
}

Expand Down
10 changes: 0 additions & 10 deletions src/js/modules/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,16 @@ export function clearDraft() {
export function deepMerge(target, source) {
if (!source || typeof source !== 'object') return target;

// Dangerous keys that could lead to prototype pollution
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];

function hasUnsafeKeys(obj, visited = new Set()) {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return false;
visited.add(obj);
return UNSAFE_KEYS.some(k => k in obj) ||
Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited));
}

Object.keys(source).forEach((key) => {
// Skip prototype pollution vectors
if (UNSAFE_KEYS.includes(key)) return;
if (!Object.prototype.hasOwnProperty.call(source, key)) return;

const value = source[key];
if (Array.isArray(value)) {
target[key] = value.slice();
} else if (value && typeof value === 'object') {
if (hasUnsafeKeys(value)) return;
if (!target[key] || typeof target[key] !== 'object') {
target[key] = {};
}
Expand Down
143 changes: 142 additions & 1 deletion tests/advanced-features.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const fs = require('fs');
const pdfParse = require('pdf-parse');
const pdfParse = require('pdf-parse/lib/pdf-parse.js');

/**
* Advanced Features Tests
Expand Down Expand Up @@ -169,4 +169,145 @@ test.describe('Advanced Invoice Features', () => {
expect(data.text).toContain(notes);
expect(data.text).toContain(instructions);
});

});

test.describe('History and JSON I/O', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();
});

test('should load invoice from history', async ({ page }) => {
// 1. Fill basic info and generate
await page.fill('#sellerName', 'History Seller');
await page.fill('#sellerAddress', 'Seller Address');
await page.fill('#billToName', 'Customer');
await page.fill('#billToAddress', 'Address');
await page.fill('#invoiceNumber', 'HIST-001');
await page.fill('.item-description', 'Item');
await page.fill('.item-quantity', '1');
await page.fill('.item-unit-price', '100');

await page.click('button:has-text("Generate Bill")');

// 2. Clear form
page.on('dialog', async d => await d.accept());
await page.click('#resetDraftBtn');
// resetDraft sets number to 1 (if HIST-001 parsing fails)
await expect(page.locator('#invoiceNumber')).not.toHaveValue('HIST-001');

// 3. Open history and load
await page.click('#historyToggle');
const historyItem = page.locator('.history-item').first();
await expect(historyItem).toBeVisible();
await historyItem.click();

// 4. Verify form is populated (wait for value)
await page.waitForFunction((num) => {
const el = document.getElementById('invoiceNumber');
return el && el.value === num;
}, 'HIST-001');

await expect(page.locator('#invoiceNumber')).toHaveValue('HIST-001');
await expect(page.locator('#sellerName')).toHaveValue('History Seller');
});

test('should delete invoice from history', async ({ page }) => {
await page.fill('#sellerName', 'Delete Seller');
await page.fill('#sellerAddress', 'Address');
await page.fill('#billToName', 'Customer');
await page.fill('#billToAddress', 'Address');
await page.fill('#invoiceNumber', 'DEL-001');
await page.fill('.item-description', 'Item');
await page.click('button:has-text("Generate Bill")');

await page.click('#historyToggle');
const historyItem = page.locator('.history-item').first();
await expect(historyItem).toBeVisible();

await historyItem.locator('.history-item-delete').click();
await expect(page.locator('.history-item')).toHaveCount(0);
await expect(page.locator('.history-empty')).toBeVisible();
});

test('should import JSON from textarea', async ({ page }) => {
const exportData = {
schemaVersion: 1,
settings: { currency: 'USD', locale: 'en-US' },
seller: { name: 'Imported Seller', address: 'Import Address', email: '', phone: '' },
billTo: { name: 'Imported Customer', address: 'Customer Address', email: '', phone: '' },
invoice: { title: 'INVOICE', number: 'IMP-123', date: '2025-01-01', dueDate: '', notes: '', instructions: '' },
items: [{ description: 'Imported Item', quantity: 5, unitPrice: 100 }],
totals: { taxRate: 0, discount: 0 },
meta: { showInvoice: false }
};

await page.fill('#jsonData', JSON.stringify(exportData));

// Verify JSON is in textarea
const val = await page.inputValue('#jsonData');
expect(val).toContain('Imported Seller');

await page.click('#importJsonBtn');

// Wait for JSON to be applied
await page.waitForFunction((name) => {
const el = document.getElementById('sellerName');
return el && el.value === name;
}, 'Imported Seller');

await expect(page.locator('#sellerName')).toHaveValue('Imported Seller');
await expect(page.locator('#invoiceNumber')).toHaveValue('IMP-123');
const items = page.locator('.item-row');
await expect(items).toHaveCount(1);
});

test('should download JSON file', async ({ page }) => {
await page.fill('#sellerName', 'Download Seller');
await page.fill('#invoiceNumber', 'JSON-DL-01');

const downloadPromise = page.waitForEvent('download');
await page.click('#downloadJsonBtn');
const download = await downloadPromise;

expect(download.suggestedFilename()).toBe('invoice-JSON-DL-01.json');
const path = await download.path();
const content = JSON.parse(fs.readFileSync(path, 'utf8'));
expect(content.seller.name).toBe('Download Seller');
expect(content.invoice.number).toBe('JSON-DL-01');
});

test('should handle large number of items for PDF generation', async ({ page }) => {
await page.fill('#sellerName', 'Large Seller');
await page.fill('#sellerAddress', 'Address');
await page.fill('#billToName', 'Customer');
await page.fill('#billToAddress', 'Address');
await page.fill('#invoiceNumber', 'LRG-001');

for (let i = 0; i < 29; i++) {
await page.click('#addItemBtn');
}

const items = page.locator('.item-row');
await expect(items).toHaveCount(30);

await items.last().locator('.item-description').fill('Last Item');
await items.last().locator('.item-quantity').fill('1');
await items.last().locator('.item-unit-price').fill('100');

await page.click('button:has-text("Generate Bill")');
await expect(page.locator('#invoice')).toBeVisible();

const downloadPromise = page.waitForEvent('download');
await page.click('#downloadBtn');
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.pdf');

const path = await download.path();
const pdfBuffer = fs.readFileSync(path);
const data = await pdfParse(pdfBuffer);
expect(data.text).toContain('Last Item');
});
});
52 changes: 50 additions & 2 deletions tests/rebill.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,41 @@ test.describe('Invoice Form', () => {
const itemTotal = page.locator('.item-total').first();
await expect(itemTotal).toContainText('2,000');
});

test('should remove line items and update totals', async ({ page }) => {
// Add second item
await page.click('#addItemBtn');
const rows = page.locator('.item-row');
await expect(rows).toHaveCount(2);

// Fill second item
await rows.nth(1).locator('.item-description').fill('Item to remove');
await rows.nth(1).locator('.item-quantity').fill('1');
await rows.nth(1).locator('.item-unit-price').fill('500');

// Verify total
await page.waitForTimeout(100);
await expect(page.locator('#formTotal')).toContainText('500.00'); // Assuming first item is empty

// Remove it
await rows.nth(1).locator('.remove-item').click();
await expect(rows).toHaveCount(1);

// Total should be 0 again if first item is empty
await page.waitForTimeout(100);
await expect(page.locator('#formTotal')).toContainText('0.00');
});

test('should increment/decrement invoice number', async ({ page }) => {
const initialValue = await page.inputValue('#invoiceNumber');
const initialNum = parseInt(initialValue);

await page.click('#incrementInvoice');
await expect(page.locator('#invoiceNumber')).toHaveValue(String(initialNum + 1));

await page.click('#decrementInvoice');
await expect(page.locator('#invoiceNumber')).toHaveValue(String(initialNum));
});
});

test.describe('Invoice Generation', () => {
Expand Down Expand Up @@ -158,8 +193,10 @@ test.describe('PDF Generation', () => {
const fs = await import('fs');
const pdfBuffer = fs.readFileSync(path);

// Dynamic import of pdf-parse
const pdfParse = (await import('pdf-parse')).default;
// Create require for pdf-parse to avoid its broken index.js debug mode
const { createRequire } = await import('module');
const require = createRequire(import.meta.url);
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
const data = await pdfParse(pdfBuffer);

// Verify Content
Expand Down Expand Up @@ -281,4 +318,15 @@ test.describe('Reset Draft', () => {
await expect(page.locator('#sellerName')).toHaveValue('');
await expect(page.locator('#billToName')).toHaveValue('');
});

test('should persist form data after reload', async ({ page }) => {
await page.goto('/');
await page.fill('#sellerName', 'Persistent Seller');

// Wait for auto-save (usually debounced)
await page.waitForTimeout(1000);

await page.reload();
await expect(page.locator('#sellerName')).toHaveValue('Persistent Seller');
});
});