diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml
new file mode 100644
index 0000000..7d554ea
--- /dev/null
+++ b/.github/workflows/browser-tests.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index b6a3318..d65e0dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a41488d..58a02c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index d895c1b..c4a8ec3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
@@ -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.
@@ -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)*
---
diff --git a/README.md b/README.md
index 1cd3fc5..0c2b0a3 100644
--- a/README.md
+++ b/README.md
@@ -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
---
@@ -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.*
diff --git a/index.html b/index.html
index ee48405..a70948c 100644
--- a/index.html
+++ b/index.html
@@ -326,7 +326,7 @@
diff --git a/js/views-settings.js b/js/views-settings.js
index 6f7d389..e208dab 100644
--- a/js/views-settings.js
+++ b/js/views-settings.js
@@ -49,7 +49,7 @@ function _renderGeneralSettings(root) {
- FinLens v1.10.5 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}
`;
+ FinLens v2.0.1 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}
`;
root.appendChild(displayCard);
/* ---- Anthropic API Key ---- */
diff --git a/tests/browser/smoke.cjs b/tests/browser/smoke.cjs
new file mode 100644
index 0000000..c475650
--- /dev/null
+++ b/tests/browser/smoke.cjs
@@ -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);
+})();