diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml
index 119ef27..1817123 100644
--- a/.github/workflows/build-extension.yml
+++ b/.github/workflows/build-extension.yml
@@ -33,7 +33,6 @@ jobs:
- name: Check JavaScript syntax
run: |
- node --check background.js
node --check content.js
node --check popup.js
@@ -55,7 +54,7 @@ jobs:
- name: Create extension archive
run: >-
zip -X extension.zip
- manifest.json content.js background.js popup.html popup.css popup.js
+ manifest.json content.js popup.html popup.css popup.js
icon.png README.md
- name: Verify archive integrity
diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml
index 1fe90ba..d93682c 100644
--- a/.github/workflows/release-extension.yml
+++ b/.github/workflows/release-extension.yml
@@ -34,7 +34,6 @@ jobs:
- name: Check JavaScript syntax
run: |
- node --check background.js
node --check content.js
node --check popup.js
@@ -47,7 +46,7 @@ jobs:
--format=zip \
--output="${RUNNER_TEMP}/extension-test.zip" \
HEAD -- \
- manifest.json content.js background.js popup.html popup.css popup.js icon.png
+ manifest.json content.js popup.html popup.css popup.js icon.png
unzip -t "${RUNNER_TEMP}/extension-test.zip"
release:
@@ -100,7 +99,7 @@ jobs:
--format=zip \
--output="${ARCHIVE_NAME}" \
HEAD -- \
- manifest.json content.js background.js popup.html popup.css popup.js icon.png
+ manifest.json content.js popup.html popup.css popup.js icon.png
unzip -t "${ARCHIVE_NAME}"
sha256sum "${ARCHIVE_NAME}" > "${ARCHIVE_NAME}.sha256"
diff --git a/AGENTS.md b/AGENTS.md
index bcd3ff1..47f2509 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,6 +3,9 @@
- Follow [CONTRIBUTING.md](CONTRIBUTING.md), including its testing and sensitive-data rules.
- Never commit HAR files, credentials, or real Credit Karma financial data.
- Run the documented checks before submitting changes.
+- If you make changes in a fork or a local checkout, open a pull request against the upstream repository (`cbangera2/CreditKarmaExtractor`) so everyone benefits. Do not leave improvements only in a fork or on a local branch; if you cannot open the pull request yourself, tell the user the exact steps.
+- Pull requests that change the popup or any other UI must include screenshots (attach them with `gh pr create --attach`).
+- Pull requests are squash merged. Tell the user before merging, and only merge once the checks pass and everything has been verified.
- Use a Conventional Commits prefix (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) in commit messages and pull request titles, and give pull requests **Summary** and **Validation** sections as described in CONTRIBUTING.md.
## AI transparency
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1a7f6ec..16c7724 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,7 +9,6 @@ Thanks for helping improve Credit Karma Data Extractor.
3. Run the local checks:
```sh
- node --check background.js
node --check content.js
node --check popup.js
node --test
@@ -47,8 +46,10 @@ If you accidentally publish a HAR containing sensitive information, remove it im
- Structure the description with these sections:
- **Summary**: what changed and why, plus the issue it relates to (use `Closes #N` only when it fully resolves the issue).
- **Validation**: the checks you ran, and anything you did not test, such as a live export on Credit Karma.
- - **Screenshots**: for popup or other UI changes. `gh pr create --attach` uploads them.
+ - **Screenshots**: required for any change to the popup or other UI. `gh pr create --attach` uploads them.
+- If you improve the extension in a fork or on a local copy, please open a pull request upstream so everyone benefits.
- Keep changes focused and avoid unrelated formatting edits.
- Add or update tests when changing parsers or export formats.
- Do not include real Credit Karma data in tests; use small synthetic fixtures.
- Ensure CI passes before requesting review.
+- Pull requests are squash merged, so the pull request title becomes the commit message on `main`.
diff --git a/background.js b/background.js
deleted file mode 100644
index f3b2379..0000000
--- a/background.js
+++ /dev/null
@@ -1,13 +0,0 @@
-chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
- if (request.action === 'captureTransactions') {
- chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {
- chrome.scripting.executeScript({
- target: {tabId: tabs[0].id},
- function: captureTransactionsInDateRange,
- args: [request.startDate, request.endDate]
- });
- sendResponse({status: 'started'});
- });
- return true; // Indicates that the response is asynchronous
- }
-});
\ No newline at end of file
diff --git a/content.js b/content.js
index 94dbd95..85f2782 100644
--- a/content.js
+++ b/content.js
@@ -1394,6 +1394,20 @@ function buildExportFileName(key, extension, dateSuffix, customNames = {}) {
: `${DEFAULT_EXPORT_BASE_NAMES[key]}_${dateSuffix}.${extension}`;
}
+/**
+ * Tell the popup (if it is still open) that an export finished or failed.
+ * Nothing is listening once the popup closes, so a missing receiver is ignored.
+ */
+function notifyExportFinished(status, message) {
+ try {
+ chrome.runtime.sendMessage({ action: 'exportFinished', status, message }, () => {
+ void chrome.runtime.lastError;
+ });
+ } catch (error) {
+ // Extension context invalidated or no receiver; nothing to do.
+ }
+}
+
function saveCSVToFile(csvData, fileName) {
const blob = new Blob([csvData], { type: 'text/csv' });
const link = document.createElement('a');
@@ -1977,7 +1991,9 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
completionNotice.style.borderRadius = '5px';
completionNotice.style.zIndex = '9999';
completionNotice.style.fontSize = '14px';
- completionNotice.textContent = `Export complete: ${summaryParts.join(' and ')}.`;
+ const completionMessage = `Export complete: ${summaryParts.join(' and ')}.`;
+ completionNotice.textContent = completionMessage;
+ notifyExportFinished('success', completionMessage);
document.body.appendChild(completionNotice);
setTimeout(() => {
@@ -1990,6 +2006,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.error('Error during data export:', error);
alert(`Error during data export: ${error.message}`);
+ notifyExportFinished('error', `Error during data export: ${error.message}`);
});
} catch (error) {
diff --git a/manifest.json b/manifest.json
index ab858b5..2c7d2f8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -25,9 +25,6 @@
]
}
],
- "background": {
- "service_worker": "background.js"
- },
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
diff --git a/popup.css b/popup.css
index dd7b583..1205e7e 100644
--- a/popup.css
+++ b/popup.css
@@ -372,6 +372,30 @@ input:checked+.slider:before {
font: inherit;
}
+.status {
+ box-sizing: border-box;
+ margin: 0 0 8px;
+ max-height: 80px;
+ overflow-y: auto;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.status[data-kind="error"] {
+ border-color: #dc2626;
+ color: #dc2626;
+}
+
+.status[data-kind="success"] {
+ border-color: #16a34a;
+ color: #16a34a;
+}
+
.secondary-btn {
width: 100%;
margin-top: 10px;
diff --git a/popup.html b/popup.html
index f37e903..e4f13d9 100644
--- a/popup.html
+++ b/popup.html
@@ -35,6 +35,8 @@
+
diff --git a/popup.js b/popup.js
index 80efaf7..30a3b62 100644
--- a/popup.js
+++ b/popup.js
@@ -103,38 +103,71 @@ for (const id of PERSISTED_VALUE_IDS) {
}
// Quick Date Logic
+function formatDateInput(date) {
+ // Format as YYYY-MM-DD using local time
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+function quickRangeValues(range, now = new Date()) {
+ let start, end;
+
+ if (range === 'last-30' || range === 'last-90') {
+ const days = range === 'last-30' ? 30 : 90;
+ start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
+ end = now;
+ } else if (range === 'ytd') {
+ start = new Date(now.getFullYear(), 0, 1);
+ end = now;
+ } else if (range === 'last-month') {
+ start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ end = new Date(now.getFullYear(), now.getMonth(), 0);
+ } else if (range === 'last-year') {
+ start = new Date(now.getFullYear() - 1, 0, 1);
+ end = new Date(now.getFullYear() - 1, 11, 31);
+ }
+
+ return start && end ? { start: formatDateInput(start), end: formatDateInput(end) } : null;
+}
+
document.querySelectorAll('.pill-btn').forEach(btn => {
btn.addEventListener('click', () => {
- const range = btn.dataset.range;
- const now = new Date();
- let start, end;
-
- if (range === 'ytd') {
- start = new Date(now.getFullYear(), 0, 1);
- end = now;
- } else if (range === 'last-month') {
- start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
- end = new Date(now.getFullYear(), now.getMonth(), 0);
- } else if (range === 'last-year') {
- start = new Date(now.getFullYear() - 1, 0, 1);
- end = new Date(now.getFullYear() - 1, 11, 31);
- }
-
- if (start && end) {
- // Format as YYYY-MM-DD using local time
- const formatDate = (date) => {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- };
- document.getElementById('start-date').value = formatDate(start);
- document.getElementById('end-date').value = formatDate(end);
+ const values = quickRangeValues(btn.dataset.range);
+ if (values) {
+ document.getElementById('start-date').value = values.start;
+ document.getElementById('end-date').value = values.end;
saveSettings();
}
});
});
+// Inline status line (replaces alert() dialogs, which are jarring in an extension popup)
+const statusLine = document.getElementById('status');
+
+// The status line lives in the fixed action bar so it is always visible without scrolling.
+// Keep the page padding in step with the bar so no content is hidden underneath it.
+function syncActionBarPadding() {
+ const bar = document.getElementById('action-bar');
+ if (bar && document.body.style) {
+ document.body.style.paddingBottom = `${bar.offsetHeight + 12}px`;
+ }
+}
+
+function showStatus(message, kind = 'info') {
+ statusLine.textContent = message;
+ statusLine.dataset.kind = kind;
+ statusLine.hidden = false;
+ syncActionBarPadding();
+}
+
+function clearStatus() {
+ statusLine.textContent = '';
+ statusLine.hidden = true;
+ syncActionBarPadding();
+}
+
/**
* Send a message to the content script. If it doesn't answer (stale or missing
* content script — e.g. the page was loaded before the extension was updated),
@@ -176,6 +209,7 @@ function sendMessageWithInjection(tab, message, callback) {
// Debug: dump raw API responses to a JSON file
document.getElementById('debug-raw-btn').addEventListener('click', () => {
+ clearStatus();
const btn = document.getElementById('debug-raw-btn');
const originalText = btn.textContent;
btn.textContent = 'Dumping...';
@@ -183,16 +217,16 @@ document.getElementById('debug-raw-btn').addEventListener('click', () => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs[0]) {
- alert('No active tab found.');
+ showStatus('No active tab found.', 'error');
btn.textContent = originalText;
btn.disabled = false;
return;
}
sendMessageWithInjection(tabs[0], { action: 'debugDumpRaw', maxHubPages: 5 }, (response, error) => {
if (error) {
- alert(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`);
+ showStatus(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`, 'error');
} else if (response?.status === 'error') {
- alert(`Raw API dump failed: ${response.message}`);
+ showStatus(`Raw API dump failed: ${response.message}`, 'error');
}
btn.textContent = originalText;
btn.disabled = false;
@@ -201,7 +235,23 @@ document.getElementById('debug-raw-btn').addEventListener('click', () => {
});
// Export Logic
+const exportBtn = document.getElementById('export-btn');
+const exportBtnText = exportBtn.textContent;
+
+function resetExportButton() {
+ exportBtn.textContent = exportBtnText;
+ exportBtn.disabled = false;
+}
+
+// The content script reports back when an export finishes (or fails)
+chrome.runtime.onMessage?.addListener(message => {
+ if (message?.action !== 'exportFinished') return;
+ showStatus(message.message, message.status === 'success' ? 'success' : 'error');
+ resetExportButton();
+});
+
document.getElementById('export-btn').addEventListener('click', () => {
+ clearStatus();
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
@@ -220,14 +270,14 @@ document.getElementById('export-btn').addEventListener('click', () => {
};
if (!Object.values(csvTypes).some(Boolean)) {
- alert('Please select at least one file to generate.');
+ showStatus('Please select at least one file to generate.', 'error');
return;
}
const needsDateRange = Object.entries(csvTypes)
.some(([type, selected]) => type !== 'wealthAccounts' && selected);
if (needsDateRange && (!startDate || !endDate)) {
- alert('Please select both start and end dates.');
+ showStatus('Please select both start and end dates.', 'error');
return;
}
@@ -244,16 +294,14 @@ document.getElementById('export-btn').addEventListener('click', () => {
};
// UI Feedback
- const exportBtn = document.getElementById('export-btn');
- const originalText = exportBtn.textContent;
exportBtn.textContent = 'Processing...';
exportBtn.disabled = true;
// Send to Content Script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs[0]) {
- alert('No active tab found.');
- resetButton();
+ showStatus('No active tab found.', 'error');
+ resetExportButton();
return;
}
@@ -270,22 +318,18 @@ document.getElementById('export-btn').addEventListener('click', () => {
// Handle error (e.g. content script not loaded)
if (error) {
console.error(error);
- alert(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`);
- resetButton();
+ showStatus(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`, 'error');
+ resetExportButton();
return;
}
if (response && response.status === 'started') {
- // Keep button disabled for a bit to prevent double clicks
- setTimeout(resetButton, 3000);
+ // Stay disabled until the content script reports the export finished
+ showStatus('Exporting... keep this Credit Karma tab open.');
} else {
- resetButton();
+ if (response?.status === 'error') showStatus(response.message, 'error');
+ resetExportButton();
}
});
});
-
- function resetButton() {
- exportBtn.textContent = originalText;
- exportBtn.disabled = false;
- }
});
diff --git a/tests/content.test.js b/tests/content.test.js
index d35f169..eb24703 100644
--- a/tests/content.test.js
+++ b/tests/content.test.js
@@ -25,7 +25,8 @@ vm.runInContext(`${contentScript}\n;globalThis.testExports = {
convertWealthAccountsToCSV,
shouldExportCurrentWealth,
buildBudgetLensBundle,
- buildExportFileName
+ buildExportFileName,
+ convertToCSV
};`, context);
const {
@@ -40,7 +41,8 @@ const {
convertWealthAccountsToCSV,
shouldExportCurrentWealth,
buildBudgetLensBundle,
- buildExportFileName
+ buildExportFileName,
+ convertToCSV
} = context.testExports;
function point(date, value) {
@@ -421,3 +423,58 @@ test('custom file names are sanitized', () => {
assert.equal(buildExportFileName('income', 'csv', 'd', { income: ' ' }), 'income_d.csv');
assert.equal(buildExportFileName('income', 'csv', 'd', { income: '...' }), 'income_d.csv');
});
+
+test('transaction CSV includes the header and quotes every field', () => {
+ const csv = convertToCSV([{
+ date: '2026-07-04',
+ description: 'Coffee',
+ amount: '4.50',
+ category: 'Dining',
+ transactionType: 'debit',
+ accountName: 'Checking',
+ accountType: 'Bank',
+ provider: 'Example Bank'
+ }]);
+
+ assert.equal(csv.split('\n')[0],
+ 'Date,Description,Amount,Category,Transaction Type,Account Name,Account Type,Provider,Labels,Notes');
+ assert.equal(csv.split('\n')[1],
+ '"07/04/2026","Coffee","4.50","Dining","debit","Checking","Bank","Example Bank","",""');
+});
+
+test('transaction CSV keeps commas, quotes, and newlines inside a single quoted field', () => {
+ const csv = convertToCSV([{
+ date: '2026-07-04',
+ description: 'Smith, John "JJ" Cafe\nDowntown',
+ amount: '1.00',
+ category: 'A, B',
+ transactionType: 'debit'
+ }], { date: true, description: true, amount: true, category: true });
+
+ assert.equal(
+ csv,
+ 'Date,Description,Amount,Category\n"07/04/2026","Smith, John ""JJ"" Cafe\nDowntown","1.00","A, B"\n'
+ );
+});
+
+test('transaction CSV only includes the selected columns', () => {
+ const csv = convertToCSV([{
+ date: '2026-07-04',
+ description: 'Coffee',
+ amount: '4.50',
+ category: 'Dining',
+ transactionType: 'debit'
+ }], { date: true, amount: true });
+
+ assert.equal(csv, 'Date,Amount\n"07/04/2026","4.50"\n');
+});
+
+test('transaction CSV leaves missing optional fields empty and handles no transactions', () => {
+ const csv = convertToCSV([{ date: '2026-07-04', amount: '1', transactionType: 'credit' }]);
+
+ assert.equal(csv.split('\n')[1], '"07/04/2026","","1","","credit","","","","",""');
+ assert.equal(
+ convertToCSV([], { date: true, amount: true }),
+ 'Date,Amount\n'
+ );
+});
diff --git a/tests/manifest.test.js b/tests/manifest.test.js
index 801963d..aa0499f 100644
--- a/tests/manifest.test.js
+++ b/tests/manifest.test.js
@@ -21,7 +21,6 @@ test('every local file referenced by the manifest exists', () => {
const referencedFiles = [
manifest.action.default_popup,
...Object.values(manifest.action.default_icon),
- manifest.background.service_worker,
...manifest.content_scripts.flatMap(contentScript => contentScript.js || [])
];
@@ -35,3 +34,7 @@ test('content scripts are limited to Credit Karma pages', () => {
assert.deepEqual(contentScript.matches, ['*://www.creditkarma.com/*']);
}
});
+
+test('manifest does not register a background service worker', () => {
+ assert.equal(manifest.background, undefined);
+});
diff --git a/tests/popup.test.js b/tests/popup.test.js
index bd9134c..abd71d4 100644
--- a/tests/popup.test.js
+++ b/tests/popup.test.js
@@ -8,7 +8,7 @@ const popupScript = fs.readFileSync(path.join(__dirname, '..', 'popup.js'), 'utf
function createPopupContext(savedStorage = {}) {
const listeners = new Map();
- const alerts = [];
+ let runtimeListener = null;
const sentMessages = [];
const checkboxIds = [
'useApiCheckbox',
@@ -46,6 +46,8 @@ function createPopupContext(savedStorage = {}) {
}
element('theme-toggle');
+ element('status').hidden = true;
+ elements.get('status').dataset = {};
element('start-date');
element('end-date');
element('export-btn');
@@ -77,10 +79,12 @@ function createPopupContext(savedStorage = {}) {
getItem: key => savedStorage[key] ?? null,
setItem(key, value) { savedStorage[key] = value; }
},
- alert: message => alerts.push(message),
setTimeout: callback => callback(),
chrome: {
- runtime: { lastError: null },
+ runtime: {
+ lastError: null,
+ onMessage: { addListener(callback) { runtimeListener = callback; } }
+ },
scripting: { executeScript() {} },
tabs: {
query(_options, callback) {
@@ -96,16 +100,23 @@ function createPopupContext(savedStorage = {}) {
vm.createContext(context);
vm.runInContext(popupScript, context);
- return { alerts, elements, listeners, sentMessages, savedStorage };
+ const status = () => {
+ const el = elements.get('status');
+ return el.hidden ? null : { text: el.textContent, kind: el.dataset.kind };
+ };
+ return {
+ context, elements, listeners, sentMessages, savedStorage, status,
+ sendRuntimeMessage: message => runtimeListener(message)
+ };
}
test('current wealth account snapshots can export without a historical date range', () => {
- const { alerts, elements, listeners, sentMessages } = createPopupContext();
+ const { status, elements, listeners, sentMessages } = createPopupContext();
elements.get('wealthAccountsCheckbox').checked = true;
listeners.get('export-btn:click')();
- assert.deepEqual(alerts, []);
+ assert.notEqual(status()?.kind, 'error');
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].csvTypes.wealthAccounts, true);
assert.equal(sentMessages[0].startDate, '');
@@ -113,24 +124,25 @@ test('current wealth account snapshots can export without a historical date rang
});
test('historical exports still require both dates', () => {
- const { alerts, elements, listeners, sentMessages } = createPopupContext();
+ const { status, elements, listeners, sentMessages } = createPopupContext();
elements.get('netWorthCheckbox').checked = true;
listeners.get('export-btn:click')();
- assert.deepEqual(alerts, ['Please select both start and end dates.']);
+ assert.equal(status().text, 'Please select both start and end dates.');
+ assert.equal(status().kind, 'error');
assert.deepEqual(sentMessages, []);
});
test('BudgetLens bundle is sent as a distinct export type', () => {
- const { alerts, elements, listeners, sentMessages } = createPopupContext();
+ const { status, elements, listeners, sentMessages } = createPopupContext();
elements.get('budgetLensBundleCheckbox').checked = true;
elements.get('start-date').value = '2026-01-01';
elements.get('end-date').value = '2026-07-30';
listeners.get('export-btn:click')();
- assert.deepEqual(alerts, []);
+ assert.notEqual(status()?.kind, 'error');
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].csvTypes.budgetLensBundle, true);
assert.equal(sentMessages[0].csvTypes.allTransactions, false);
@@ -189,3 +201,83 @@ test('corrupt or partial saved settings fall back to the defaults', () => {
assert.equal(partial.elements.get('col-date').checked, true);
assert.equal(partial.elements.get('end-date').value, '');
});
+
+test('selecting no export shows an inline error instead of an alert', () => {
+ const { elements, listeners, sentMessages, status } = createPopupContext();
+ elements.get('budgetLensBundleCheckbox').checked = false;
+
+ listeners.get('export-btn:click')();
+
+ assert.equal(status().text, 'Please select at least one file to generate.');
+ assert.equal(status().kind, 'error');
+ assert.equal(sentMessages.length, 0);
+});
+
+test('export button stays disabled until the content script reports completion', () => {
+ const { elements, listeners, status, sendRuntimeMessage } = createPopupContext();
+ elements.get('wealthAccountsCheckbox').checked = true;
+
+ listeners.get('export-btn:click')();
+
+ assert.equal(elements.get('export-btn').disabled, true);
+ assert.equal(status().kind, 'info');
+
+ sendRuntimeMessage({ action: 'exportFinished', status: 'success', message: 'Export complete: 3 transactions.' });
+
+ assert.equal(elements.get('export-btn').disabled, false);
+ assert.equal(status().text, 'Export complete: 3 transactions.');
+ assert.equal(status().kind, 'success');
+});
+
+test('export failures reported by the content script are shown and re-enable the button', () => {
+ const { elements, listeners, status, sendRuntimeMessage } = createPopupContext();
+ elements.get('wealthAccountsCheckbox').checked = true;
+ listeners.get('export-btn:click')();
+
+ sendRuntimeMessage({ action: 'exportFinished', status: 'error', message: 'Error during data export: boom' });
+
+ assert.equal(elements.get('export-btn').disabled, false);
+ assert.equal(status().text, 'Error during data export: boom');
+ assert.equal(status().kind, 'error');
+});
+
+test('unrelated runtime messages are ignored', () => {
+ const { elements, listeners, status, sendRuntimeMessage } = createPopupContext();
+ elements.get('wealthAccountsCheckbox').checked = true;
+ listeners.get('export-btn:click')();
+
+ sendRuntimeMessage({ action: 'somethingElse' });
+
+ assert.equal(elements.get('export-btn').disabled, true);
+ assert.equal(status().kind, 'info');
+});
+
+test('quick date ranges cover the expected days', () => {
+ const { context } = createPopupContext();
+ const now = new Date(2026, 6, 30); // July 30, 2026
+ const range = name => {
+ const { start, end } = context.quickRangeValues(name, now);
+ return `${start}..${end}`;
+ };
+
+ assert.equal(range('last-30'), '2026-06-30..2026-07-30');
+ assert.equal(range('last-90'), '2026-05-01..2026-07-30');
+ assert.equal(range('ytd'), '2026-01-01..2026-07-30');
+ assert.equal(range('last-month'), '2026-06-01..2026-06-30');
+ assert.equal(range('last-year'), '2025-01-01..2025-12-31');
+});
+
+test('quick date ranges handle month and year boundaries', () => {
+ const { context } = createPopupContext();
+ const march = new Date(2026, 2, 1);
+ const january = new Date(2026, 0, 5);
+ const range = (name, now) => {
+ const { start, end } = context.quickRangeValues(name, now);
+ return `${start}..${end}`;
+ };
+
+ assert.equal(range('last-30', march), '2026-01-30..2026-03-01');
+ assert.equal(range('last-month', january), '2025-12-01..2025-12-31');
+ assert.equal(range('last-90', january), '2025-10-07..2026-01-05');
+ assert.equal(context.quickRangeValues('nonsense', march), null);
+});