Skip to content
Draft
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
40 changes: 40 additions & 0 deletions .github/workflows/browser-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Browser tests

# Boots FinLens in a real browser, renders every view, imports samples/ and
# checks the numbers. Runs on every pull request and every push to master.
on:
pull_request:
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: browser-tests-${{ github.ref }}
cancel-in-progress: true

jobs:
smoke:
name: Smoke tests (${{ matrix.browser }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
browser: [chromium, msedge]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# Test tooling only. The app itself still has no npm and no build step.
- name: Install Playwright
run: |
npm install --no-save --no-package-lock playwright@1.63.0
npx playwright install --with-deps ${{ matrix.browser }}
- name: Run smoke tests
env:
BROWSER_CHANNEL: ${{ matrix.browser }}
run: node tests/browser/smoke.cjs
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ finlens-analysis-*.md
# Editor artifacts
.vscode/
*.swp

# Test tooling installed by CI or locally (the app has no npm)
node_modules/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [2.0.1] — 2026-11-04

### Technical — Tests
- **Browser smoke tests in CI** (`tests/browser/smoke.cjs`, `.github/workflows/browser-tests.yml`). On every pull request and every push to `master`, FinLens is served over HTTP and opened in Chromium and Edge (Playwright). Nothing in the app changes.
- **Boot:** no page errors, console errors or dialogs, and the footer shows the CHANGELOG version.
- **Every view:** all registered views render with no errors, both empty and after importing the samples.
- **Sample import:** every row of every file in `samples/` imports, one account per file, amounts are whole cents, dates are valid, ids are unique, transfers are detected, income − spending equals the net of non-transfer rows to the cent, and re-importing the same files adds no rows.
- **Known bugs** (`KNOWN_BUGS`) run as expected failures: the job stays green while they fail and goes red when a fix makes one pass, so the list is updated in the fixing PR. First entry: `fmt()` shows "-$0.00" for amounts that round to zero.
- **CLAUDE.md §0:** how to run the smoke tests and keep `KNOWN_BUGS` current. `node_modules/` is git-ignored (test tooling only).
- Part of the **v2.0 — Public launch** milestone. Left open on purpose until the rename and custom domain are ready.

---

## [1.10.5] — 2026-11-02

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Nothing reaches `master` except through a reviewed pull request. This is the own
- **CHANGELOG and version** bumped per §17.
- **A description** with a summary, the cause (for bugs), what changed, how it was tested, and a "please check before merging" list.
- **Tests run** before opening the PR (headless browser checks against `samples/`). Report results honestly, including anything not verified.
- **Smoke tests:** `node tests/browser/smoke.cjs` (needs Playwright). CI runs it on every PR in Chromium and Edge (`.github/workflows/browser-tests.yml`). A known bug goes in its `KNOWN_BUGS` list; when a fix makes it pass, remove it from the list in the same PR.

### Stacked PRs

Expand Down Expand Up @@ -59,7 +60,7 @@ Until then, this section is the rule.

FinLens is a **single-origin, zero-build, vanilla JavaScript personal finance dashboard** that runs entirely in the browser. There is no server, no npm, no bundler, no framework, and no TypeScript. The entry point is `index.html`; everything else is a plain `.js` file in the `js/` folder.

**Current version:** v1.10.5 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release).
**Current version:** v2.0.1 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release).

**What it does:** Imports CSV exports from Ally Bank (Checking + Savings) and Chase credit cards, parses and normalizes them into a unified transaction model, and renders a multi-tab financial dashboard with budgeting, debt tracking, savings forecasting, investment planning, and Claude-powered AI analysis.

Expand Down Expand Up @@ -798,7 +799,7 @@ Multiple Chase card files can be loaded simultaneously. Each gets a unique `acco

---

*Last updated: v1.10.5 — 2026-11-02 (no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)*
*Last updated: v2.0.1 — 2026-11-04 (§0 smoke tests and KNOWN_BUGS; v1.10.5 — 2026-11-02: no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)*

---

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> A zero-install, browser-native finance dashboard. Drop in your bank CSVs, get instant clarity on your money.

**Version:** v1.10.5  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None
**Version:** v2.0.1  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None

---

Expand Down Expand Up @@ -260,4 +260,4 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.

---

*FinLens v1.10.5 — Built for one person's finances, designed to be useful for anyone's.*
*FinLens v2.0.1 — Built for one person's finances, designed to be useful for anyone's.*
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
<div style="margin-top:auto">
<a class="nav" data-view="settings">⚙️ Settings</a>
<div class="footer">
<div style="color:var(--text-3);font-size:11px">v1.10.5 · FinLens</div>
<div style="color:var(--text-3);font-size:11px">v2.0.1 · FinLens</div>
</div>
</div>
</aside>
Expand Down
2 changes: 1 addition & 1 deletion js/views-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function _renderGeneralSettings(root) {
</select>
</div>
</div>
<div class="muted" style="font-size:11.5px;margin-top:14px">FinLens v1.10.5 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}</div>`;
<div class="muted" style="font-size:11.5px;margin-top:14px">FinLens v2.0.1 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}</div>`;
root.appendChild(displayCard);

/* ---- Anthropic API Key ---- */
Expand Down
179 changes: 179 additions & 0 deletions tests/browser/smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// FinLens browser smoke tests — run with: node tests/browser/smoke.cjs
//
// Serves the repo over HTTP, opens it in a real browser (Playwright) and checks:
// 1. the app boots with no errors
// 2. every registered view renders with no errors, empty and with sample data
// 3. the files in samples/ import, and the numbers are sane to the cent
// 4. known bugs (KNOWN_BUGS) still fail — when one starts passing, CI says so
//
// Uses only samples/ (synthetic data). Never point it at real exports.
//
// Env:
// BROWSER_CHANNEL ''/chromium (default) or msedge
// CDN_MIRROR optional folder holding local copies of the CDN scripts, for
// machines that can't reach the CDNs. CI uses the real CDNs.
const { chromium } = require('playwright');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const http = require('http');

const ROOT = path.resolve(__dirname, '..', '..');
const PORT = 8000 + Math.floor(Math.random() * 900);
const BASE = `http://127.0.0.1:${PORT}/`;
const CHANNEL = process.env.BROWSER_CHANNEL && process.env.BROWSER_CHANNEL !== 'chromium'
? process.env.BROWSER_CHANNEL : undefined;

// Known bugs: each is a check that SHOULD pass once the bug is fixed.
// While it still fails, CI stays green and reports it as "expected failure".
// When a fix makes it pass, CI fails and asks you to remove it from this list,
// so a fixed bug is locked in by a normal test instead of quietly coming back.
const KNOWN_BUGS = new Set([
'fmt never shows -$0.00',
]);

let passed = 0, failed = 0, xfail = 0, xpass = 0;
function result(name, ok, detail) {
const known = KNOWN_BUGS.has(name);
if (ok && !known) { passed++; console.log(` ok ${name}`); }
else if (!ok && known) { xfail++; console.log(` xfail ${name} (known bug)${detail ? ' — ' + detail : ''}`); }
else if (ok && known) { xpass++; console.log(`::error::"${name}" now passes. Remove it from KNOWN_BUGS in tests/browser/smoke.cjs.`); }
else { failed++; console.log(`::error::${name}${detail ? ' — ' + detail : ''}`); }
}

function waitForServer(url, tries = 50) {
return new Promise((resolve, reject) => {
const attempt = (n) => http.get(url, (r) => { r.resume(); resolve(); })
.on('error', () => n ? setTimeout(() => attempt(n - 1), 100) : reject(new Error('server did not start')));
attempt(tries);
});
}

(async () => {
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'],
{ cwd: ROOT, stdio: 'ignore' });
let browser;
try {
await waitForServer(BASE);
browser = await chromium.launch(CHANNEL ? { channel: CHANNEL } : {});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });

const errors = [];
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`); });
page.on('dialog', (d) => { errors.push(`dialog: ${d.message()}`); d.dismiss(); });

if (process.env.CDN_MIRROR) {
const mirror = process.env.CDN_MIRROR;
await page.route(/^https:\/\/(cdnjs\.cloudflare\.com|cdn\.jsdelivr\.net)\//, (route) => {
const file = path.join(mirror, path.basename(new URL(route.request().url()).pathname));
if (fs.existsSync(file)) route.fulfill({ path: file, contentType: 'application/javascript' });
else route.abort();
});
await page.route(/^https:\/\/fonts\.(googleapis|gstatic)\.com\//, (r) => r.abort());
// Aborted font requests log console errors; they are not app errors.
page.removeAllListeners('console');
page.on('console', (m) => {
if (m.type() === 'error' && !/fonts\.g|ERR_FAILED/.test(m.text())) errors.push(`console.error: ${m.text()}`);
});
}

console.log(`Browser: ${CHANNEL || 'chromium'} ${browser.version()}`);

// 1. Boot ----------------------------------------------------------------
console.log('Boot');
await page.goto(BASE + 'index.html', { waitUntil: 'load' });
await page.waitForFunction(() => typeof VIEWS === 'object' && document.querySelector('.view.active'), null, { timeout: 15000 });
await page.waitForTimeout(500);
result('app boots with no errors', errors.length === 0, errors.join(' | '));
const footer = await page.locator('text=/v\\d+\\.\\d+\\.\\d+ · /').first().textContent().catch(() => '');
const version = (fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').match(/## \[(\d+\.\d+\.\d+)\]/) || [])[1];
result('footer shows the CHANGELOG version', footer.includes(`v${version}`), `footer "${footer.trim()}", CHANGELOG ${version}`);

// 2. Every view, empty ---------------------------------------------------
async function renderAllViews(label) {
const ids = await page.evaluate(() => Object.keys(VIEWS));
const bad = [];
for (const id of ids) {
errors.length = 0;
await page.evaluate((v) => showView(v), id);
await page.waitForTimeout(80); // charts draw in setTimeout(..., 0)
if (errors.length) bad.push(`${id}: ${errors.join(' | ')}`);
}
result(`every view renders with no errors (${label}, ${ids.length} views)`, bad.length === 0, bad.join(' || '));
return ids;
}
console.log('Views, no data');
await renderAllViews('no data');

// 3. Import samples ------------------------------------------------------
console.log('Sample import');
const samples = fs.readdirSync(path.join(ROOT, 'samples')).filter((f) => f.endsWith('.csv')).sort();
const csvRows = samples.reduce((n, f) =>
n + fs.readFileSync(path.join(ROOT, 'samples', f), 'utf8').trim().split(/\r?\n/).length - 1, 0);
errors.length = 0;
const stats = await page.evaluate(async (names) => {
const files = [];
for (const n of names) {
const text = await (await fetch('samples/' + n, { cache: 'no-store' })).text();
files.push(new File([text], n, { type: 'text/csv' }));
}
await loadFiles(files);
const tx = State.transactions;
const cents = (x) => Math.round(x * 100);
const transfers = tx.filter((t) => t.type === 'transfer');
return {
count: tx.length,
accounts: Object.keys(State.accounts).length,
badAmounts: tx.filter((t) => !Number.isFinite(t.amount) || Math.abs(t.amount * 100 - cents(t.amount)) > 1e-6).length,
badDates: tx.filter((t) => !(t.date instanceof Date) || isNaN(t.date)).length,
dupIds: tx.length - new Set(tx.map((t) => t.id)).size,
transferCount: transfers.length,
transferNetCents: transfers.reduce((s, t) => s + cents(t.amount), 0),
incomeCents: cents(sumIncome(tx)),
expenseCents: cents(sumExpense(tx)),
netCents: tx.filter((t) => t.type !== 'transfer' && t.type !== 'payment').reduce((s, t) => s + cents(t.amount), 0),
};
}, samples);
console.log(' ' + JSON.stringify(stats));
result('sample import has no errors', errors.length === 0, errors.join(' | '));
result(`every sample row imported (${csvRows} rows in ${samples.length} files)`, stats.count === csvRows, `${stats.count} transactions`);
result('one account per sample file', stats.accounts === samples.length, `${stats.accounts} accounts`);
result('every amount is a finite number in whole cents', stats.badAmounts === 0, `${stats.badAmounts} bad`);
result('every date is a valid Date', stats.badDates === 0, `${stats.badDates} bad`);
result('transaction ids are unique', stats.dupIds === 0, `${stats.dupIds} duplicates`);
result('transfers are detected in the samples', stats.transferCount > 0, `${stats.transferCount} transfers`);
result('income − spending equals the net of non-transfer rows, to the cent',
stats.incomeCents - stats.expenseCents === stats.netCents,
`income ${stats.incomeCents} − spending ${stats.expenseCents} ≠ net ${stats.netCents} (cents)`);

// 4. Views with data, and money formatting ------------------------------
console.log('Views, sample data');
await renderAllViews('sample data');

const fmtChecks = await page.evaluate(() => ({
negZero: [fmt(-0), fmt(-0.001), fmt(-0.004)],
cents: [fmt(0.1 + 0.2), fmt(1234.5), fmt(-12.345)],
}));
console.log(' ' + JSON.stringify(fmtChecks));
result('fmt never shows -$0.00', !fmtChecks.negZero.some((s) => /^-\$0\.00$|^\$-0\.00$/.test(s)), fmtChecks.negZero.join(', '));
result('fmt rounds float drift to the cent', fmtChecks.cents[0] === '$0.30' && fmtChecks.cents[1] === '$1,234.50', fmtChecks.cents.join(', '));

// Re-importing the same files must not add rows
const again = await page.evaluate(async (names) => {
const files = [];
for (const n of names) files.push(new File([await (await fetch('samples/' + n)).text()], n, { type: 'text/csv' }));
await loadFiles(files);
return State.transactions.length;
}, samples);
result('re-importing the same files adds no rows', again === stats.count, `${stats.count} → ${again}`);
} catch (e) {
failed++;
console.log(`::error::test run crashed — ${e.stack || e}`);
} finally {
if (browser) await browser.close();
server.kill();
}
console.log(`\n${passed} passed, ${failed} failed, ${xfail} known bugs still failing, ${xpass} known bugs now fixed`);
process.exit(failed || xpass ? 1 : 0);
})();
Loading