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 @@
⚙️ Settings
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); +})();