diff --git a/docs/performance/date-range-clock.md b/docs/performance/date-range-clock.md new file mode 100644 index 0000000..12bccba --- /dev/null +++ b/docs/performance/date-range-clock.md @@ -0,0 +1,58 @@ +# Date-filter clock allocation + +The `All time` filter previously constructed a default `Date` for each transfer before returning `true`. Production commit `9f1debe8fb5ba5f79fc6fe83349d4264faa7b13b` defers that clock until a nonzero date range needs it. Explicit caller clocks, inclusive cutoffs, invalid-date handling, and active-range behavior are preserved. The selection repair and peer pagination optimization remain intact. + +## Native measurement — 4 October 2026 + +Node **22.16.0**, Linux x64, 100,000 records. Three warmups per implementation, then nine alternating paired samples. Times measure `Array.filter` calling the complete production helper, not rendering, API calls, storage, or full-page latency. Native clocks are used during timing; fixed-clock compatibility checks and allocation instrumentation are separate and untimed. Output membership, order and object references matched. + +All time median: **12.115259 → 4.508355 ms**, **62.79% less time**. Untimed default-clock allocations fall **100,000 → 0**. + +The active 30-day control measured **62.660949 → 60.724011 ms** with **100,000 default-clock allocations in both versions**. That small difference is not treated as a material active-filter improvement; raw samples vary. + +Raw paired milliseconds (baseline, candidate), in execution order: + +```text +All time: +11.827676 4.604149 +12.521932 4.508355 +12.354159 4.730149 +12.115259 4.209475 +11.933276 4.286680 +12.185305 4.649849 +12.091634 4.467593 +12.650215 4.578040 +12.020006 4.353351 + +30d: +57.947415 61.450144 +67.611370 59.298888 +62.732698 61.345878 +59.746007 60.724011 +62.942054 58.739440 +62.660949 61.517115 +57.514723 58.042810 +63.476268 61.369984 +59.706392 59.051290 +``` + +Baseline helper blob `ec341ab656c3091f5f2d37037e7e2e4c6214bf08`, SHA-256 `091b841c006388822c59e0282aa8a5be3124870a678907218581aaf1cab6b660`. Executed/published candidate blob `697f74780073bc37775051341776eb54efc99b65`, SHA-256 `289356fe227fe6dd540326ae8637d584358a17355267c4613ae84d27b2f29006`. + +## Reproduce + +From the repository root with Node.js installed: + +```sh +tmp=$(mktemp -d) +git show 521ef150b7da2a0d93bebfd9537a5ce1c9d2897f:src/utils/dateRange.js > "$tmp/baseline.mjs" +git show 9f1debe8fb5ba5f79fc6fe83349d4264faa7b13b:src/utils/dateRange.js > "$tmp/candidate.mjs" +node docs/performance/date-range-clock.mjs "$tmp/baseline.mjs" "$tmp/candidate.mjs" +``` + +The driver checks omitted and supplied clocks, empty/invalid/oversized range tokens, invalid timestamps and the inclusive boundary. Its control fixture uses fixed historical timestamps; active-range result counts change with the real run date. The recorded run retained 100,000 All time records and 30,000 30-day records. Timings are environment-specific observations, not a universal multiplier. + +## Existing application checks + +[Native run 37206173166](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37206173166) is successful on pinned source `9f1debe8fb5ba5f79fc6fe83349d4264faa7b13b`. All **eight unchanged** `test/integration/transfers-filter.test.jsx` cases pass, including no-filter display, date preset selection and URL restoration. The original package lock is installed with `npm ci --no-audit --no-fund`; scoped source formatting and the clean-worktree check pass. No new test file or dependency was added for this continuation. + +The separate five-case selection result remains bound to production commit `6ed4086fa9520c36639b5419a42145951a16cd9a` in `docs/validation/across-page-deselection.md`; it is not added to a current-head suite total. No full build, complete suite, deployed throughput, acceptance or payout is asserted here. diff --git a/docs/performance/date-range-clock.mjs b/docs/performance/date-range-clock.mjs new file mode 100644 index 0000000..c15fbca --- /dev/null +++ b/docs/performance/date-range-clock.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import crypto from 'node:crypto'; + +const [baselinePath, candidatePath] = process.argv.slice(2); +if (!baselinePath || !candidatePath) throw new Error('Usage: node date-range-bench.mjs BASELINE CANDIDATE'); +const baseline = (await import(pathToFileURL(baselinePath))).isWithinDateRange; +const candidate = (await import(pathToFileURL(candidatePath))).isWithinDateRange; +const realDate = Date; +const fixedNow = '2026-10-04T12:00:00.000Z'; +let clockAllocations = 0; +class FixedDate extends realDate { + constructor(...args) { + if (!args.length) { clockAllocations++; super(fixedNow); } + else super(...args); + } +} +const timestamps = ['2026-09-27T11:59:59.999Z', '2026-09-27T12:00:00.000Z', '2026-10-04T12:00:00.000Z', '2026-10-05T12:00:00.000Z', 'invalid', null, 0]; +const ranges = ['', undefined, null, '0d', 'bad', '7d', '30d', '90d', '9'.repeat(320) + 'd']; +const clocks = [undefined, new Date(fixedNow), new Date('invalid')]; +globalThis.Date = FixedDate; +let comparisons = 0; +try { + for (const created of timestamps) for (const range of ranges) for (const now of clocks) { + assert.equal(candidate(created, range, now), baseline(created, range, now)); + comparisons++; + } + for (const fn of [baseline, candidate]) { + assert.equal(fn('2026-09-27T11:59:59.999Z', '7d'), false); + assert.equal(fn('2026-09-27T12:00:00.000Z', '7d'), true); + assert.throws(() => fn(fixedNow, '7d', null), TypeError); + } +} finally { globalThis.Date = realDate; } + +const rows = Array.from({ length: 100_000 }, (_, i) => ({ id: i, createdAt: new Date(Date.parse(fixedNow) - (i % 100) * 86400000).toISOString() })); +const allocationCounts = []; +for (const range of ['', '30d']) { + const counts = []; + globalThis.Date = FixedDate; + try { + for (const fn of [baseline, candidate]) { + clockAllocations = 0; + rows.filter(row => fn(row.createdAt, range)); + counts.push(clockAllocations); + } + } finally { globalThis.Date = realDate; } + allocationCounts.push({ range, baseline: counts[0], candidate: counts[1] }); +} +const workloads = []; +const median = xs => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; +for (const range of ['', '30d']) { + // Native clock is used during measurement. Fixtures are hours from any edge. + const before = rows.filter(row => baseline(row.createdAt, range)); + const after = rows.filter(row => candidate(row.createdAt, range)); + assert.equal(before.length, after.length); + before.forEach((row, i) => assert.equal(row, after[i])); + const run = fn => { const start = performance.now(); const result = rows.filter(row => fn(row.createdAt, range)); return { ms: performance.now() - start, count: result.length }; }; + for (let i = 0; i < 3; i++) { run(baseline); run(candidate); } + const samples = []; + for (let i = 0; i < 9; i++) { + let a, b; + if (i % 2) { b = run(candidate); a = run(baseline); } + else { a = run(baseline); b = run(candidate); } + assert.equal(a.count, b.count); + samples.push({ baseline_ms: a.ms, candidate_ms: b.ms }); + } + workloads.push({ range, count: before.length, baseline_median_ms: median(samples.map(s => s.baseline_ms)), candidate_median_ms: median(samples.map(s => s.candidate_ms)), samples }); +} +const hash = path => crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'); +console.log(JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch, baseline_sha256: hash(baselinePath), candidate_sha256: hash(candidatePath), comparisons, allocationCounts, workloads }, null, 2)); diff --git a/docs/performance/snapshot-early-stop-20261004/README.md b/docs/performance/snapshot-early-stop-20261004/README.md new file mode 100644 index 0000000..e5d5081 --- /dev/null +++ b/docs/performance/snapshot-early-stop-20261004/README.md @@ -0,0 +1,70 @@ +# Snapshot page lookup: early termination + +This continuation of PR297 changes only live-record lookup in +`src/utils/transferSnapshot.js`. The preceding bounded lookup remains; no +full-history map or persistent cache is introduced. + +For ordinary arrays, scanning backward makes the first match the last duplicate +in input order. Removing resolved IDs from the page-sized set permits an early +stop once every visible ID is found. Empty pages read no records. Missing IDs +still require a full scan and retain their snapshot fallback. Sets, generators, +and arrays with custom iterators keep the forward-only duplicate policy. + +## Executed source and results + +Baseline commit: `3bb8747d62c1e9690f9a1559c7f6d73f792bd9d1`. +Baseline source blob: `74893db1e8b99de594c40305b1428f83ae3c7926`. +Candidate source blob: `f12b9a0163b8d47b8d0e903764245ae18a1ea2a2`. + +Node v22.16.0, Linux x64, AMD EPYC 9V74. Complete unmodified modules were +imported, not extracted functions. Nine alternating pairs, twenty calls per +sample, three warmup samples. Medians are milliseconds per `pageFromSnapshot` +call; source loading, fixture/snapshot creation and assertions are untimed. + +| 100,000-row workload, five-row page | Before | After | +| --- | ---: | ---: | +| Ascending input, first/newest page | 2.793942 | 0.007302 | +| Descending input, first/newest page | 2.754172 | 2.726743 | +| One visible ID missing (99,999 live rows) | 2.621030 | 2.336080 | +| Empty filtered snapshot | 1.590464 | 0.002271 | +| Last duplicate appended (100,001 live rows) | 2.790019 | 0.005145 | +| Ascending input, last/oldest page | 2.798958 | 2.804947 | + +All six complete output objects and returned record references agreed. +Three focused assertion groups passed: duplicate/fallback/cursor/iterable +compatibility; empty-page work elimination; early termination and missing-ID +full-scan behavior. Untimed access counts distinguished actual work: an empty +page read 25 to 0 IDs; a five-row tail page over 30 rows read 35 to 10 IDs +(including the second ID read used to insert a match). Frozen inputs and the +snapshot stayed unchanged. Scope rejection, expiry and page-two cursor behavior +were included in the compatibility group. + +These are input-position-dependent improvements, not a general 100,000-row +constant-time claim. The last-page full-scan median was essentially unchanged, +slightly higher in this run. Full scans remain O(N), with O(page size) auxiliary +storage. No browser, rendering, storage, provider, quota or whole-application +speedup is asserted. Prior benchmark ratios must not be multiplied by these +results. + +## Reproduce + +From a checkout containing this continuation: + +```sh +work=$(mktemp -d) +git show 3bb8747d62c1e9690f9a1559c7f6d73f792bd9d1:src/utils/transferSnapshot.js > "$work/base.mjs" +cp src/utils/transferSnapshot.js "$work/candidate.mjs" +node docs/performance/snapshot-early-stop-20261004/check-and-bench.mjs \ + "$work/base.mjs" "$work/candidate.mjs" +``` + +`results.json` retains every timed sample and source identity. The script uses +only Node built-ins and exits nonzero on an assertion failure. The first local +invocation stopped before imports because its argument mapper passed unwanted +Array.map parameters to path.resolve; that script-only error was corrected before +the retained execution. No product change was made in response to that error. + +No dependency installation, Vitest suite, production build, new workflow or +external provider call was performed for this continuation. Existing maintained +tests were not changed or weakened; their historical results are not represented +as a new run at this source. Sponsor review and acceptance remain separate. diff --git a/docs/performance/snapshot-early-stop-20261004/check-and-bench.mjs b/docs/performance/snapshot-early-stop-20261004/check-and-bench.mjs new file mode 100644 index 0000000..f88c042 --- /dev/null +++ b/docs/performance/snapshot-early-stop-20261004/check-and-bench.mjs @@ -0,0 +1,159 @@ +// Usage: node check-and-bench.mjs +// Complete source modules are imported, not extracted or rewritten functions. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import os from 'node:os'; + +const [basePath, nextPath] = process.argv.slice(2).map((path) => resolve(path)); +if (!basePath || !nextPath) throw new Error('Provide baseline and candidate module paths'); +const base = await import(pathToFileURL(basePath).href); +const next = await import(pathToFileURL(nextPath).href); +const now = Date.parse('2026-10-04T12:00:00Z'); +const options = { now }; +const row = (id, i, extra = {}) => ({ + id, + createdAt: new Date(now - 1_000_000 + i).toISOString(), + recipient: `recipient-${id}`, + status: 'pending', + ...extra, +}); +const snapshotOf = (rows, name, pageSize = 5) => + base.createTransferSnapshot(rows, { now, pageSize, snapshotId: name }); +const equivalent = (snapshot, live, opts = options) => { + const before = base.pageFromSnapshot(snapshot, live, opts); + const after = next.pageFromSnapshot(snapshot, live, opts); + assert.deepEqual(after, before); + after.items.forEach((item, i) => assert.equal(item, before.items[i])); + return after; +}; +const regressions = []; + +// 1. Last duplicate wins, missing rows fall back, pagination and stale scopes +// stay identical. Forward-only and custom array iterators keep their ordering. +{ + const original = Array.from({ length: 7 }, (_, i) => row(`r${i}`, i)); + const snapshot = snapshotOf(original, 'compat'); + const duplicate = { ...original[6], status: 'completed' }; + const live = Object.freeze([...original.filter((r) => r.id !== 'r5'), duplicate]); + const captured = JSON.stringify(snapshot); + const first = equivalent(snapshot, live); + assert.equal(first.items[0], duplicate); + assert.equal(first.items[1], snapshot.items[1].record); + equivalent(snapshot, live, { now, cursor: first.nextCursor }); + equivalent(snapshot, live, { now, cursor: 'bad' }); + equivalent(snapshot, live, { now, filters: { status: 'failed' }, cursor: first.cursor }); + equivalent(snapshot, live, { now: now + base.SNAPSHOT_TTL_MS }); + equivalent(snapshot, new Set(live)); + const custom = [...live]; + custom[Symbol.iterator] = function* () { yield* live.slice().reverse(); }; + equivalent(snapshot, custom); + const generator = () => (function* () { yield* live; })(); + const expected = base.pageFromSnapshot(snapshot, generator(), options); + const actual = next.pageFromSnapshot(snapshot, generator(), options); + assert.deepEqual(actual, expected); + actual.items.forEach((value, i) => assert.equal(value, expected.items[i])); + equivalent(snapshot, null); + assert.equal(JSON.stringify(snapshot), captured); + regressions.push('duplicate/fallback/cursor/iterable compatibility'); +} + +// 2. Empty snapshot skips all live rows; count accesses outside timed work. +{ + const snapshot = snapshotOf([], 'empty'); + let reads = 0; + const live = Array.from({ length: 25 }, (_, i) => ({ get id() { reads++; return `r${i}`; } })); + const expected = base.pageFromSnapshot(snapshot, live, options); + assert.equal(reads, 25); + reads = 0; + assert.deepEqual(next.pageFromSnapshot(snapshot, live, options), expected); + assert.equal(reads, 0); + regressions.push('empty snapshot: 25 to 0 live-ID reads'); +} + +// 3. Complete tail match stops after its five records, even with older +// duplicates earlier in the array. A missing ID still inspects all rows. +{ + const original = Array.from({ length: 30 }, (_, i) => row(`r${i}`, i)); + const snapshot = snapshotOf(original, 'tail'); + let reads = 0; + const observe = (records) => records.map((r) => ({ + ...r, get id() { reads++; return r.id; }, + })); + const live = observe(original); + equivalent(snapshot, live); + reads = 0; + next.pageFromSnapshot(snapshot, live, options); + // The matched id is read for delete and Map insertion, as in the baseline. + assert.equal(reads, 10); + reads = 0; + base.pageFromSnapshot(snapshot, live, options); + assert.equal(reads, 35); + const missing = observe(original.filter((r) => r.id !== 'r29')); + reads = 0; + next.pageFromSnapshot(snapshot, missing, options); + assert.equal(reads, 29 + 4); + regressions.push('tail early-stop and missing-ID full-scan'); +} + +const rows = Object.freeze(Array.from({ length: 100_000 }, (_, i) => Object.freeze(row(`t${i}`, i)))); +const full = snapshotOf(rows, 'large'); +const empty = snapshotOf([], 'empty-large'); +const reversed = Object.freeze(rows.slice().reverse()); +const missing = Object.freeze(rows.slice(0, -1)); +const duplicateTail = Object.freeze([...rows, { ...rows.at(-1), status: 'completed' }]); +const workloads = [ + ['ascending-first-page', full, rows, options], + ['descending-first-page', full, reversed, options], + ['missing-one-visible-id', full, missing, options], + ['empty-filtered-snapshot', empty, rows, options], + ['duplicate-tail', full, duplicateTail, options], + ['ascending-last-page', full, rows, { now, cursor: base.encodeCursor({ + snapshotId: full.id, scope: full.scope, page: 20_000, after: full.items[99_994], + }) }], +]; +let sink = 0; +const measure = (mod, snapshot, live, opts) => { + const start = performance.now(); + for (let i = 0; i < 20; i++) sink += mod.pageFromSnapshot(snapshot, live, opts).items.length; + return (performance.now() - start) / 20; +}; +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const measurements = []; +for (const [name, snapshot, live, opts] of workloads) { + equivalent(snapshot, live, opts); + for (let warm = 0; warm < 3; warm++) { + measure(base, snapshot, live, opts); + measure(next, snapshot, live, opts); + } + const before = [], after = []; + for (let i = 0; i < 9; i++) { + if (i % 2) { + after.push(measure(next, snapshot, live, opts)); + before.push(measure(base, snapshot, live, opts)); + } else { + before.push(measure(base, snapshot, live, opts)); + after.push(measure(next, snapshot, live, opts)); + } + } + measurements.push({ name, rows: live.length, page_size: snapshot.pageSize, + baseline_ms: median(before), candidate_ms: median(after), + baseline_samples_ms: before, candidate_samples_ms: after, + }); +} +const blob = (path) => { + const bytes = readFileSync(path); + return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +}; +console.log(JSON.stringify({ + recorded_at: new Date().toISOString(), node: process.version, + platform: `${process.platform}/${process.arch}`, cpu: os.cpus()[0]?.model, + baseline_blob: blob(basePath), candidate_blob: blob(nextPath), + regression_groups_passed: regressions, workload_outputs_and_references_equal: true, + pairs: 9, calls_per_sample: 20, warmup_samples: 3, + scope: 'complete pure-module paging; excludes snapshot construction, filtering, UI, storage and network', + measurements, sink, +}, null, 2)); diff --git a/docs/performance/snapshot-early-stop-20261004/results.json b/docs/performance/snapshot-early-stop-20261004/results.json new file mode 100644 index 0000000..cfc57a7 --- /dev/null +++ b/docs/performance/snapshot-early-stop-20261004/results.json @@ -0,0 +1,195 @@ +{ + "recorded_at": "2026-10-04T13:17:48.273Z", + "node": "v22.16.0", + "platform": "linux/x64", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "baseline_blob": "74893db1e8b99de594c40305b1428f83ae3c7926", + "candidate_blob": "f12b9a0163b8d47b8d0e903764245ae18a1ea2a2", + "regression_groups_passed": [ + "duplicate/fallback/cursor/iterable compatibility", + "empty snapshot: 25 to 0 live-ID reads", + "tail early-stop and missing-ID full-scan" + ], + "workload_outputs_and_references_equal": true, + "pairs": 9, + "calls_per_sample": 20, + "warmup_samples": 3, + "scope": "complete pure-module paging; excludes snapshot construction, filtering, UI, storage and network", + "measurements": [ + { + "name": "ascending-first-page", + "rows": 100000, + "page_size": 5, + "baseline_ms": 2.793942200000001, + "candidate_ms": 0.007301949999998669, + "baseline_samples_ms": [ + 2.793942200000001, + 2.7656307999999994, + 3.90399635, + 3.0391704000000006, + 3.1521723500000007, + 3.195225399999998, + 2.6557861000000003, + 2.641682399999996, + 2.7898761500000036 + ], + "candidate_samples_ms": [ + 0.01230899999999906, + 0.00568604999999991, + 0.010474699999997483, + 0.005842250000000604, + 0.007577850000001263, + 0.005859300000003032, + 0.007301949999998669, + 0.006604899999996405, + 0.007566349999996192 + ] + }, + { + "name": "descending-first-page", + "rows": 100000, + "page_size": 5, + "baseline_ms": 2.754172049999988, + "candidate_ms": 2.726742999999999, + "baseline_samples_ms": [ + 3.044228499999997, + 2.5985562500000015, + 2.720730949999995, + 2.6609617999999955, + 2.9884664000000045, + 2.6213164000000004, + 2.791974300000004, + 2.754172049999988, + 2.830057850000003 + ], + "candidate_samples_ms": [ + 2.726742999999999, + 2.9281900000000065, + 2.6060269500000004, + 4.573309950000009, + 2.7246208000000025, + 2.6492162000000006, + 2.756903699999998, + 2.698314800000003, + 2.8166342500000154 + ] + }, + { + "name": "missing-one-visible-id", + "rows": 99999, + "page_size": 5, + "baseline_ms": 2.621029950000002, + "candidate_ms": 2.336080199999992, + "baseline_samples_ms": [ + 2.669087000000013, + 2.9442997000000104, + 3.144783050000001, + 2.658250249999992, + 2.502926400000001, + 2.536457650000011, + 2.621029950000002, + 2.6196333500000035, + 2.5505603000000066 + ], + "candidate_samples_ms": [ + 2.514036800000008, + 2.7613698999999996, + 2.336080199999992, + 2.2952039499999954, + 2.3186836000000084, + 2.3009896000000025, + 2.4023841500000116, + 2.2788093500000057, + 2.3830411500000084 + ] + }, + { + "name": "empty-filtered-snapshot", + "rows": 100000, + "page_size": 5, + "baseline_ms": 1.5904642500000137, + "candidate_ms": 0.002270900000007714, + "baseline_samples_ms": [ + 1.559064699999999, + 1.5032048999999916, + 1.5963505499999884, + 1.5904642500000137, + 1.5847786999999927, + 1.6300075500000049, + 1.7748462000000018, + 1.5782689499999834, + 1.691841249999993 + ], + "candidate_samples_ms": [ + 0.0022919500000170958, + 0.0020876499999985753, + 0.0023660500000005414, + 0.0021361999999953696, + 0.002270900000007714, + 0.00209664999999859, + 0.0037000499999976454, + 0.0021207000000003974, + 0.0022979500000019472 + ] + }, + { + "name": "duplicate-tail", + "rows": 100001, + "page_size": 5, + "baseline_ms": 2.790018850000024, + "candidate_ms": 0.005145200000015393, + "baseline_samples_ms": [ + 2.7127995499999997, + 2.8180569000000104, + 2.707391899999993, + 2.6737615000000003, + 2.790018850000024, + 2.9024408499999934, + 2.82385905000001, + 2.844551599999977, + 2.6880419000000075 + ], + "candidate_samples_ms": [ + 0.005070599999999104, + 0.004614450000008219, + 0.00517174999999952, + 0.004525250000006054, + 0.005151249999971697, + 0.004525800000010349, + 0.01765894999998636, + 0.01667649999999412, + 0.005145200000015393 + ] + }, + { + "name": "ascending-last-page", + "rows": 100000, + "page_size": 5, + "baseline_ms": 2.7989582499999868, + "candidate_ms": 2.804947200000015, + "baseline_samples_ms": [ + 2.7633929499999796, + 2.673776050000015, + 2.8919922000000042, + 2.8251905500000247, + 2.993982649999998, + 3.0399110500000006, + 2.788988249999966, + 2.7989582499999868, + 2.669397500000014 + ], + "candidate_samples_ms": [ + 2.804947200000015, + 2.678881199999978, + 3.441855499999974, + 2.801076400000011, + 2.9089747499999703, + 2.869080900000017, + 2.7090514500000156, + 2.9047473999999966, + 2.7223809500000242 + ] + } + ], + "sink": 12000 +} diff --git a/docs/performance/snapshot-page-lookup-20261004.md b/docs/performance/snapshot-page-lookup-20261004.md new file mode 100644 index 0000000..f1d7405 --- /dev/null +++ b/docs/performance/snapshot-page-lookup-20261004.md @@ -0,0 +1,66 @@ +# Snapshot page lookup — October 4, 2026 + +## Change and scope + +Source commit: `45b60908dbb19e16fad573c1c7d8a0768b62071f`. +Parent: `5ef95f76c2b77abd8458cbdbc3ca4582e4fad19c`. +Production file: `src/utils/transferSnapshot.js`. +Before blob: `f2269a1e6fea33e1ae01fa63614067ed3505627a`. +After blob: `74893db1e8b99de594c40305b1428f83ae3c7926`. + +`pageFromSnapshot` formerly built a complete live-history Map and an intermediate array of pairs for every page. It now selects the visible snapshot IDs first and retains live records only for those IDs. The scan still covers the full live list, so the last occurrence of a duplicate ID wins exactly as before. There is no persistent cache: each call sees current live fields, while missing rows fall back to their captured snapshot records. Invalid or expired cursors return before constructing a live lookup. + +The live scan remains O(N). Its lookup and selected-ID storage are O(K), where K is the number of distinct IDs on the page, rather than O(N). This does not reduce the memory occupied by the full input or snapshot. A page as large as the history does not get the same bounded-storage advantage. Constructor sorting, generation IDs, snapshot membership, cursor encoding and expiry are unchanged. + +## Executed measurement + +Runtime: Node **22.16.0**, Linux x64. The complete before and after production modules were executed directly, without substitutes for their helpers. Both source blobs were checked. The [retained program](snapshot-page-lookup-20261004.mjs) completed with exit 0. + +Each workload uses its middle page. Fixture and snapshot construction are outside timing. There are three warmup batches and seven alternating paired samples; each sample is a mean per call within its stated batch. Explicit GC runs outside each timed batch. The table shows medians of the seven samples, not whole-page browser latency. + +| Live rows | Page size | Calls/sample | Before ms | After ms | Ratio | Observed lookup entries | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 100 | 5 | 100 | 0.020131 | 0.016869 | 1.19x | 100 → 5 | +| 5,000 | 5 | 20 | 0.493533 | 0.091068 | 5.42x | 5,000 → 5 | +| 100,000 | 5 | 3 | 14.268989 | 1.605926 | 8.89x | 100,000 → 5 | +| 100,000 | 100 | 3 | 12.362670 | 2.287825 | 5.40x | 100,000 → 100 | + +Map entry counts come from a separate untimed observation of the actual lookup. They are not measured peak heap bytes. Microsecond-scale results and early warmup/JIT variation should not be overinterpreted. + +### Raw per-call samples, milliseconds + +```json +[ + {"rows":100,"pageSize":5, + "before":[0.020904550000000056,0.019497279999999933,0.01819867000000002,0.019946429999999963,0.020131609999999966,0.02829431999999997,0.020130809999999996], + "after":[0.018028210000000086,0.018327660000000065,0.01532865000000001,0.011325760000000002,0.011858349999999972,0.01686930999999987,0.018421589999999953]}, + {"rows":5000,"pageSize":5, + "before":[0.5121488,0.4935328999999996,0.43395425000000076,0.48608490000000015,0.503021849999999,0.7880583000000001,0.48717710000000236], + "after":[0.2903851000000003,0.2652936999999994,0.08597545000000081,0.09106749999999977,0.09093775000000051,0.07686855000000037,0.10441905000000133]}, + {"rows":100000,"pageSize":5, + "before":[14.268988666666663,11.53207100000001,17.691984666666674,15.693690333333356,11.250089666666668,14.46507866666665,12.394973666666601], + "after":[1.6874616666666877,1.6644346666666554,1.591400333333354,1.6059256666666595,1.5914606666666486,1.5756039999999985,1.9031326666666548]}, + {"rows":100000,"pageSize":100, + "before":[13.55541733333333,12.140986999999996,12.891106666666625,11.468530999999984,11.978391000000025,15.603217666666675,12.362669666666685], + "after":[4.9937950000000155,2.3221353333333354,2.2647876666666584,2.2925716666666176,2.2407690000000002,2.2878249999999603,2.2664766666666765]} +] +``` + +## Compatibility observed + +Complete output envelopes and every returned row's object identity match the parent on the four large/small measurement workloads. The same program separately checks first/middle/final paging; insertion exclusion; current-field updates; last-duplicate selection; missing-row fallback; malformed and cross-filter cursors; expired and missing snapshots; empty and absent live lists; and `resolveTransferPage` composition with the original snapshot and unchanged input contents. + +These are focused direct-module observations, not a Vitest or application-suite result. Existing tests, dependencies and workflow files were not changed. The earlier source-pinned constructor and application results remain historical; they were not rerun for this change. No React rendering, HTTP/API, browser, production build, hosted-CI, deployed-performance, sponsor-acceptance or payment result is asserted. + +## Reproduce from an existing checkout + +No dependency installation is required; the modules use native Node facilities. Preserve the supplied input versions rather than silently comparing later source. + +```sh +work=$(mktemp -d) +git show 5ef95f76c2b77abd8458cbdbc3ca4582e4fad19c:src/utils/transferSnapshot.js > "$work/before.mjs" +git show 45b60908dbb19e16fad573c1c7d8a0768b62071f:src/utils/transferSnapshot.js > "$work/after.mjs" +git show d81632ab7aa3518e49d10d7b69c307b14cbcef0e:docs/performance/snapshot-page-lookup-20261004.mjs > "$work/measure.mjs" +node --expose-gc "$work/measure.mjs" +# The program also writes "$work/result.json". +``` diff --git a/docs/performance/snapshot-page-lookup-20261004.mjs b/docs/performance/snapshot-page-lookup-20261004.mjs new file mode 100644 index 0000000..9e3ae3c --- /dev/null +++ b/docs/performance/snapshot-page-lookup-20261004.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import * as before from './before.mjs'; +import * as after from './after.mjs'; + +const now = Date.parse('2026-10-04T12:00:00Z'); +const makeRows = (count) => Array.from({ length: count }, (_, i) => ({ + id: `tx-${i}`, createdAt: new Date(now - i * 1000).toISOString(), status: 'pending', +})); +const rows = makeRows(14); +const snapshot = before.createTransferSnapshot(rows, { now, snapshotId: 'fixed' }); +const first = before.pageFromSnapshot(snapshot, rows, { now }); +const second = before.pageFromSnapshot(snapshot, rows, { now, cursor: first.nextCursor }); +const same = (snap, live, options) => { + const left = before.pageFromSnapshot(snap, live, options); + const right = after.pageFromSnapshot(snap, live, options); + assert.deepEqual(right, left); + right.items.forEach((row, index) => assert.equal(row, left.items[index])); + return right; +}; +const checks = []; +for (const cursor of [null, first.nextCursor, second.nextCursor]) { + same(snapshot, rows, { now, cursor }); +} +checks.push('First, middle and final pages preserve complete results, cursors and live references.'); +const updated = { ...rows[5], status: 'claimed' }; +const duplicate = { ...updated, status: 'cancelled' }; +const changedRows = [...rows.filter(row => row.id !== rows[6].id), updated, + { id: 'new', createdAt: new Date(now + 1000).toISOString() }, duplicate]; +const changed = same(snapshot, changedRows, { now, cursor: first.nextCursor }); +assert.equal(changed.items[0], duplicate); +assert.equal(changed.items[1], snapshot.items[6].record); +assert.equal(changed.totalCount, 14); +assert.equal(changed.items.some(row => row.id === 'new'), false); +checks.push('New inserts stay excluded; removed rows use snapshot fallback; last duplicate wins with its exact reference.'); +for (const options of [{ now, cursor: 'malformed' }, + { now, filters: { search: 'other' }, cursor: first.nextCursor }, + { now: now + before.SNAPSHOT_TTL_MS, cursor: first.nextCursor }]) { + assert.equal(same(snapshot, rows, options).ok, false); +} +const empty = before.createTransferSnapshot([], { now, snapshotId: 'empty' }); +same(empty, [], { now }); +same(snapshot, undefined, { now }); +same(null, rows, { now }); +checks.push('Invalid and cross-filter cursors, expired/missing snapshots, empty history and absent live rows preserve returned envelopes.'); +const historical = rows.map(row => ({ ...row })); +const state = { now, snapshot, cursor: first.nextCursor, liveTransfers: changedRows }; +const resolvedBefore = before.resolveTransferPage(rows, state); +const resolvedAfter = after.resolveTransferPage(rows, state); +assert.deepEqual(resolvedAfter, resolvedBefore); +resolvedAfter.page.items.forEach((row, index) => assert.equal(row, resolvedBefore.page.items[index])); +assert.deepEqual(rows, historical); +checks.push('Public resolveTransferPage composition preserves the supplied snapshot, output references and input contents.'); + +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; +const workloads = []; +for (const [count, pageSize, repeats] of [[100, 5, 100], [5000, 5, 20], [100000, 5, 3], [100000, 100, 3]]) { + const live = makeRows(count); + const snap = before.createTransferSnapshot(live, { now, pageSize, snapshotId: `bench-${count}-${pageSize}` }); + const page = Math.max(1, Math.floor(Math.ceil(count / pageSize) / 2)); + const cursor = before.encodeCursor({ snapshotId: snap.id, scope: snap.scope, page, + after: snap.items[(page - 1) * pageSize - 1] ?? null }); + const options = { now, cursor }; + same(snap, live, options); + const run = (mod) => { + const begin = performance.now(); + let result; + for (let i = 0; i < repeats; i++) result = mod.pageFromSnapshot(snap, live, options); + const elapsed = (performance.now() - begin) / repeats; + assert.equal(result.items.length, pageSize); + return elapsed; + }; + for (let i = 0; i < 3; i++) { run(before); run(after); } + const left = [], right = []; + for (let i = 0; i < 7; i++) { + for (const [module, samples] of (i % 2 ? [[after, right], [before, left]] : [[before, left], [after, right]])) { + global.gc?.(); + samples.push(run(module)); + } + } + const mapSizes = (module) => { + const OriginalMap = global.Map; + const maps = []; + global.Map = class extends OriginalMap { constructor(values) { super(values); maps.push(this); } }; + try { module.pageFromSnapshot(snap, live, options); } finally { global.Map = OriginalMap; } + return maps.map(map => map.size); + }; + const oldMaps = mapSizes(before), newMaps = mapSizes(after); + assert.deepEqual(oldMaps, [count]); + assert.deepEqual(newMaps, [pageSize]); + workloads.push({ count, pageSize, repeats, before_ms: left, after_ms: right, + before_median_ms: median(left), after_median_ms: median(right), + ratio: median(left) / median(right), map_entries_before: oldMaps, map_entries_after: newMaps }); +} +const blob = (path) => { + const bytes = readFileSync(new URL(path, import.meta.url)); + return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); +}; +assert.equal(blob('./before.mjs'), 'f2269a1e6fea33e1ae01fa63614067ed3505627a'); +assert.equal(blob('./after.mjs'), '74893db1e8b99de594c40305b1428f83ae3c7926'); +const result = { node: process.version, platform: process.platform, arch: process.arch, + source_parent: '5ef95f76c2b77abd8458cbdbc3ca4582e4fad19c', + before_blob: blob('./before.mjs'), after_blob: blob('./after.mjs'), + checks, warmup_batches: 3, alternating_pairs: 7, workloads, + limits: 'Pure production-module execution only; snapshot creation and fixture setup excluded from timed work. Map sizes observed separately, not heap memory measurements. No React, HTTP, dependency install, Vitest, build or hosted CI execution.' }; +writeFileSync(new URL('./result.json', import.meta.url), JSON.stringify(result, null, 2) + '\n'); +console.log(JSON.stringify(result, null, 2)); diff --git a/docs/performance/snapshot-sort-20261004.md b/docs/performance/snapshot-sort-20261004.md new file mode 100644 index 0000000..3f90e4f --- /dev/null +++ b/docs/performance/snapshot-sort-20261004.md @@ -0,0 +1,50 @@ +# Snapshot construction: prepare sort keys once + +Continuation of [issue 278](https://github.com/RemitFlow/RemitFlow-Frontend/issues/278) on the original [PR297](https://github.com/RemitFlow/RemitFlow-Frontend/pull/297). + +The comparator previously parsed two timestamps on every comparison. The sorter now prepares each populated row's timestamp and string ID once, sorts those keys and returns the original row references. It retains the existing descending time order, locale-aware ID comparison, stable equal-key order, invalid-date fallback, and placement of undefined entries and sparse slots. The snapshot still contains every row; no result cap or cache is introduced. Cursor generation, expiry, recovery, live-field refresh and selection behavior are unchanged. + +## Measured constructor performance + +Node 22.16.0, Linux x64, AMD EPYC 9V74. These are calls to the actual `createTransferSnapshot` module, including record copying, with the same fixed time and caller-supplied snapshot ID in both versions. Each workload uses two warmups followed by five paired measurements with alternating execution order. Fixture generation, garbage collection, complete-output comparison and operation counting are outside the timer. + +| History | Before ms | After ms | Speedup | +| -------------------- | --------: | -------: | ------: | +| 5,000 shuffled | 20.349 | 3.071 | 6.63x | +| 100,000 shuffled | 620.098 | 80.338 | 7.72x | +| 100,000 oldest-first | 37.541 | 28.876 | 1.30x | + +Every paired snapshot output matched completely. The 100,000-row shuffled workload makes 100,000 timestamp parses instead of 3,058,470. A separate ordering comparison preserves row identity, Unicode locale ties, duplicate keys, invalid/epoch/pre-epoch timestamps, null, undefined and sparse slots. + +The tradeoff is one temporary prepared-key record per populated row. This is not a peak-memory measurement. Results exclude storage parsing, network/provider work and React rendering; no end-to-end page or fleet-throughput speedup is claimed. + +[Exact benchmark program](https://github.com/woahwhattheheck/RemitFlow-Frontend/blob/1329f02f4fc35716243cae0341cf3834d20f0109/work/snapshot-sort-20261004/benchmark.mjs) and [all paired samples](https://github.com/woahwhattheheck/RemitFlow-Frontend/blob/1329f02f4fc35716243cae0341cf3834d20f0109/work/snapshot-sort-20261004/results.json) are retained separately from the product branch. Baseline source blob: `9bc1fc12653b8e05eca72094ec6a737d9164ef72`; measured candidate blob: `f2269a1e6fea33e1ae01fa63614067ed3505627a`. + +## Executed focused checks + +[GitHub Actions run 37201593676](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37201593676) checked out exact source `2ae93359afb19092961f328377955106259251d4` and succeeded on October 4, 2026. Node 22.23.3 and the unchanged lockfile supplied Vitest 4.1.10 and Vite 5.4.21. + +The eleven existing snapshot utility cases and two added sort regressions passed together: **13 passed, zero failed or skipped**, in 1.53 seconds. Existing cases cover concurrent inserts, expiry, filter reset, complete large-fixture paging, live-field refresh and distinct snapshot generations. The added cases check ordering compatibility and one timestamp parse per row. No existing test was removed, skipped or weakened. + +The two changed JavaScript files passed the existing Prettier check. The production application built successfully: 119 modules, Vite-reported 917 ms. The job confirmed unchanged tracked contents after execution and printed the exact product and test blobs: + +```text +src/utils/transferSnapshot.js f2269a1e6fea33e1ae01fa63614067ed3505627a +test/unit/transfer-snapshot-sort.test.js 0d8d2a8ade90e9e567ac5d9d7274b814f59bec7d +``` + +The final integration retains those tested bytes and the concurrent report-receipt cleanup from `db690af3e2eded4f66fd99b68e654ca0ff6909b3`. The isolated execution workflow is not part of the product contribution. The earlier 477-case aggregate result remains bound to its own source; it was not rerun or relabeled as a current whole-suite result. Upstream CI approval, maintainer acceptance and any reward remain separate. + +## Reproduce + +From an installed checkout of the contribution: + +```bash +npx --no-install vitest run test/unit/transfer-snapshot.test.js test/unit/transfer-snapshot-sort.test.js --maxWorkers=1 --fileParallelism=false +npm run build + +git fetch https://github.com/woahwhattheheck/RemitFlow-Frontend.git 1329f02f4fc35716243cae0341cf3834d20f0109 +git show 1329f02f4fc35716243cae0341cf3834d20f0109:work/snapshot-sort-20261004/benchmark.mjs > /tmp/rf297-benchmark.mjs +git show d86d94b516e2a496bb94ae7e627b1451ce83f2d0:src/utils/transferSnapshot.js > /tmp/rf297-baseline.mjs +node --expose-gc /tmp/rf297-benchmark.mjs /tmp/rf297-baseline.mjs src/utils/transferSnapshot.js +``` diff --git a/docs/validation/across-page-deselection.md b/docs/validation/across-page-deselection.md new file mode 100644 index 0000000..aa721d8 --- /dev/null +++ b/docs/validation/across-page-deselection.md @@ -0,0 +1,21 @@ +# Across-page deselection — 4 October 2026 + +Selecting every page used an implicit flag with an empty explicit-ID set. The first row toggle consequently selected only that row; a page toggle cleared all pages. The two handlers now materialize the frozen snapshot's IDs before removing the requested row or page. Other-page selections survive, and newer live rows are not pulled into the selection. + +Production commit: `6ed4086fa9520c36639b5419a42145951a16cd9a`, a direct child of `afa3b0efb5c7e0bd2eee2e6123c1206e7c801ac8`. The existing early-stop lookup and its performance evidence remain unchanged. No dependency, workflow, or unrelated source changes are part of this fix. + +## Focused execution + +The existing `test/integration/select-all-across-pages.test.jsx` file has three unchanged cases and two new parameterized cases. Both new cases select seven transfers, move to the final page, deselect a row or that page, and verify that the five rows on the other page remain selected. They mount the real application under the repository's existing test configuration. + +- Baseline component `3bb8747d62c1e9690f9a1559c7f6d73f792bd9d1`: **3 passed, 2 failed**; only the new row/page deselection cases fail. +- Candidate `d0e0940be428c1743c87f7f42e2eec48b60cca2d`: **5 passed, 0 failed**. [Paired execution](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37205575772) completed both test steps; its final formatting check failed on two ternary line wraps, subsequently corrected. +- Final composed production source `6ed4086fa9520c36639b5419a42145951a16cd9a`: **5 passed, 0 failed, 0 pending**; scoped Prettier check and clean-worktree check passed. [Successful final run](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37205924647). + +Node **24.21.0**, npm **11.19.0**, unchanged lockfile installed with `npm ci --no-audit --no-fund`. Commands: `./node_modules/.bin/vitest run test/integration/select-all-across-pages.test.jsx` and `./node_modules/.bin/prettier --check src/pages/Transfers.jsx test/integration/select-all-across-pages.test.jsx`. + +Executed blobs: component `d1d765c64e27832d46450fb2e5c7e5cbb5c7ca12`; selection test `f030d294dabe1f91f5fc66505f1a987b9ec4010c`; preserved snapshot helper `f12b9a0163b8d47b8d0e903764245ae18a1ea2a2`. + +[Final raw reports](https://github.com/woahwhattheheck/RemitFlow-Frontend/actions/runs/37205924647/artifacts/11304383975), ZIP SHA-256 `e16c0ab61df0c9accc1fcc916bce057a7eb11d93d8a4921131c492fdb38f5d1a`, expire on 11 October 2026. The paired run's artifact is `11303953775`. An earlier controller-only failure in run `37205456958` incorrectly expected unquoted parameter names in the JSON report; the test counts were already 3 passed/2 failed. No production assertion was weakened. + +These results cover selection interactions in the mounted test application, not a full build, complete suite, deployed provider, or payment acceptance. The temporary execution workflow stays on an isolated verification branch and is not included in the contribution. diff --git a/docs/validation/rf297-existing-gates-20261004/README.md b/docs/validation/rf297-existing-gates-20261004/README.md new file mode 100644 index 0000000..5ecd7f8 --- /dev/null +++ b/docs/validation/rf297-existing-gates-20261004/README.md @@ -0,0 +1,73 @@ +# PR297: existing CI gate execution + +The complete local test selection passed **477/477 tests across 49 files**, with zero failed, pending, or todo tests. The existing formatting check and production build also passed. These results close the local acceptance work required by issue 278; hosted CI, Lighthouse, sponsor acceptance and reward payment are separate. + +## Source and scope + +- Original contribution: [RemitFlow Frontend PR297](https://github.com/RemitFlow/RemitFlow-Frontend/pull/297), issue 278. +- Executed source: [`157ff7bffe98c96ad48b39c844ff51a0a070679a`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/157ff7bffe98c96ad48b39c844ff51a0a070679a). +- Exact source tree: `a4af225ae83e3f688ef7fa7c0e73d00d6c998bff`; 344 tracked blobs and modes. +- Parent: `aa146d6c8280554cf95849f9912e8a9c1d21baf2`, retaining the snapshot-selection recovery fix and all earlier pagination work. +- The documentation child adds only this report, receipt and raw archive. Its application and test source matches the executed source. + +Four existing fixture files reuse the already-executed PR296 corrections from `17dd96c4593fcd599286d263ed7cb72f6a4b7c20`. Each original fixture blob matched before composition. Confirmation and result navigation now follow the existing UI; offline blocking, retry, exactly-once creation, quote precision and receipt assertions remain. Canonical decimal assertions follow existing quote behavior. No test title or case was removed or skipped. + +The one production correction in `SendMoney.jsx` is derived from this branch: when a validated positive amount cannot be priced, the review step reports a pricing error. It preserves this branch's wallet, submission and page contracts. The four fixture files were copied only after their full original blob identities matched; `SendMoney.jsx` was not copied from another PR. + +Prettier 3.9.6 identified nine files needing formatting. Only those flagged paths were formatted. Thirteen paths changed overall; package metadata, dependency lock, workflow/config files and the prior snapshot-selection product fix remain unchanged. + +## Results + +| Gate | Result | Observation | +| ------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Complete package test selection | **477 passed, 0 failed, 0 pending, 0 todo; 49/49 files** | One aggregate execution ; 57.113 s process, 56.57 s Vitest | +| Existing Prettier check | **Passed** | Nine flagged paths corrected; final check exit 0 | +| Production build | **Passed** | `npm run build`, exit 0; 2.309 s command, Vite 5.4.21 reported 1.55 s; 119 modules | +| Critical audit threshold | **Shared fresh receipt reused: 0 critical** | Actual shared scan at 11:56:36Z for identical dependency blobs; no duplicate scan | + +Tests started 2026-10-04T11:54:56.918428Z and finished 11:55:54.031767Z. The build ran 11:56:29.250298Z–11:56:31.558839Z. Every one of the 344 tracked source files matched the manifest before and after both commands. The JSON reporter includes nested describe-suite totals; 49 is the actual test-file count from its `testResults` array. + +## Reproduction + +With this source and its locked dependencies installed in a writable environment: + +```sh +npm run format:check +npm test -- --maxWorkers=2 +npm run build +``` + +The actual cloud command invokes the same complete selection declared by `package.json`: + +```sh +node node_modules/vitest/vitest.mjs run \ + test/integration test/unit test/components test/services \ + test/lighthouse-config.test.js test/security \ + --config /dev/shm/relay17-rf297-acceptance-evidence/vitest.config.mjs \ + --maxWorkers=2 --reporter=default --reporter=json \ + --outputFile.json=/dev/shm/relay17-rf297-acceptance-evidence/vitest-results.json +``` + +Node 24.19.0, Vitest 4.1.10 and the existing React/jsdom stack executed the tests. The retained dependency installation was reused only after `package.json`, `package-lock.json`, the repository Vite config and `test/setup.js` matched exactly. The temporary config imports the original repository config and changes only `cacheDir`. `TMPDIR` was also redirected into task-owned scratch because the root filesystem was full. These path substitutions do not alter test discovery, setup, assertions or timeouts. The archive includes the exact wrapper/config and full commands. + +## Audit freshness and validation limits + +The reused audit actually ran from 2026-10-04T11:56:36.112260Z to 11:56:56.026595Z in the concurrently executing PR298 validation with `npm audit --audit-level=critical --json --fetch-retries=0 --fetch-timeout=20000`; it exited 0. Dependency identities are unchanged: + +- `package.json`: `625d0f3895fcc7afbfcfa131713249f5cd65dcd6`. +- `package-lock.json`: `70d29c1865dac7ce76ba7d4e6ca5ee454be54aae`. + +Both source trees were checked to contain those exact dependency bytes before reuse. The source commit's earlier audit note refers to the 10:00:59Z receipt; this immediate documentation child supplies the newer shared audit without repeating the network request. The donor run receipt is included for provenance; its PR298 application test/build results are not claimed for PR297. + +That receipt reports **20 high, 12 moderate and 2 low findings**, with 0 critical. This work does not claim those 34 noncritical findings are repaired or that PR297 performed an independent advisory scan. The original machine-readable audit output and donor run receipt are preserved unchanged; the reused-audit receipt extracts its audit fields and records the checked dependency identities. + +This is local application/build evidence using the repository's demonstration wallet and transfer service. It does not establish a live payment, provider transaction, signing or settlement. React/Vite/Router and environment warning output remains in the raw logs. No warning-free, hosted CI, Lighthouse performance or production deployment result is claimed. The prior full-suite baseline was not repeated; the source changes reuse the already-established fixture repairs. + +## Raw evidence + +- [Machine-readable receipt](receipt.json): source and file identities, commands, counts, timings and archive/member digests. +- [Raw evidence archive](evidence.zip): 16 entries, including unmodified Vitest JSON/output, formatting/build output, shared audit evidence and complete source/composition manifests. +- ZIP bytes: `60612`. +- ZIP SHA256: `838969807560e6854a77c084694bf730e13ecd792046d2ad9d82860a358ec450`. + +Original contribution, claim, branch and publisher custody remain intact. diff --git a/docs/validation/rf297-existing-gates-20261004/evidence.zip b/docs/validation/rf297-existing-gates-20261004/evidence.zip new file mode 100644 index 0000000..a9dc6c7 Binary files /dev/null and b/docs/validation/rf297-existing-gates-20261004/evidence.zip differ diff --git a/docs/validation/rf297-existing-gates-20261004/receipt.json b/docs/validation/rf297-existing-gates-20261004/receipt.json new file mode 100644 index 0000000..e9b8966 --- /dev/null +++ b/docs/validation/rf297-existing-gates-20261004/receipt.json @@ -0,0 +1,282 @@ +{ + "recorded_at_utc": "2026-10-04T11:58:18.730792+00:00", + "repository": "woahwhattheheck/RemitFlow-Frontend", + "upstream_pr": "https://github.com/RemitFlow/RemitFlow-Frontend/pull/297", + "issue": 278, + "original_branch": "goat/remitflow-278-transfer-pagination", + "parent_commit": "aa146d6c8280554cf95849f9912e8a9c1d21baf2", + "executed_source_commit": "157ff7bffe98c96ad48b39c844ff51a0a070679a", + "executed_source_tree": "a4af225ae83e3f688ef7fa7c0e73d00d6c998bff", + "tracked_files": 344, + "changed_files": [ + { + "path": "src/context/AppContext.jsx", + "before": "336af1b39fb49c8121788391c76ef1aef0791619", + "after": "452bf9fdb731bedaf2fa1064596a6c7bfc5feea3", + "bytes": 4484 + }, + { + "path": "src/hooks/useTransfers.js", + "before": "dea1e619cc558f315daeb33e19ec5105e2270754", + "after": "80160bb5b91b87b3f42dbd56b2a95a1ad3d250a6", + "bytes": 2192 + }, + { + "path": "src/pages/SendMoney.jsx", + "before": "c1e03f0316f49bdb5f3c88578ba6794931238afe", + "after": "9c9a9066743dfc6700ca3fe917d6d794b861b8be", + "bytes": 15265 + }, + { + "path": "src/services/errors.js", + "before": "bab520ff3605f35b46173bc9cf9fdb2e7b9708b6", + "after": "4ecc020e8d880ba5139d6403607dbdaaf3199fdb", + "bytes": 2805 + }, + { + "path": "test/components/WalletButton.test.jsx", + "before": "46287e2d2a90a62307486aaefd632a7e75aaf246", + "after": "701fc6fe35087bc054b650a322e6228a3a620ada", + "bytes": 5274 + }, + { + "path": "test/integration/offline-reconnect.test.jsx", + "before": "10a876166e35b45e55280924fc155d5d5cb76729", + "after": "a6de5816f9644c998768461c010fcd91f5afae4d", + "bytes": 7059 + }, + { + "path": "test/integration/send-money-form.test.jsx", + "before": "43a73813b6bf47bcc06cae0958c8d39cb2acce86", + "after": "9ae992d9c2680a1fe133cfa01e9ce1e212fc3973", + "bytes": 7754 + }, + { + "path": "test/integration/send-money-precision.test.jsx", + "before": "f5cf076d246e83b9afa97d78ae2ab33fee7741ea", + "after": "136b66cc62ddf4cd2fa1b5b2a2891145434bdcb1", + "bytes": 9091 + }, + { + "path": "test/unit/AppContext.wallet.test.jsx", + "before": "20c04bf16087385efb0c78824fdadbb58614a849", + "after": "4436e1d658770334e2859957f06610f5f307b0e6", + "bytes": 6975 + }, + { + "path": "test/unit/quote.test.js", + "before": "0fcfe522ab545678df6418decdc3d2d9d6a6dab7", + "after": "94611389f9c1496e1d1abfd35cb4c608d9887f71", + "bytes": 1025 + }, + { + "path": "test/unit/transfer-snapshot.test.js", + "before": "d063bc0d9ba9a9670e0e8d75703e5376c2ef6e47", + "after": "da9ab4f799f994eeb7327613f41d434156b0b600", + "bytes": 10831 + }, + { + "path": "test/unit/useTransfers.errors.test.jsx", + "before": "071426f8b4b87b2f1606beca6b2d41927d13cbfc", + "after": "11741da257f62807c44e2f15bcfc6244ccd533cc", + "bytes": 1283 + }, + { + "path": "vite-plugin-security-headers.js", + "before": "af430cf049287c4ec656a7c852d9865da56d671b", + "after": "d3fb1d78206415e9580807b92b7a378350eeac16", + "bytes": 4616 + } + ], + "test": { + "started_at_utc": "2026-10-04T11:54:56.918428+00:00", + "finished_at_utc": "2026-10-04T11:55:54.031767+00:00", + "elapsed_seconds": 57.11334453699965, + "exit_code": 0, + "command": [ + "node", + "node_modules/vitest/vitest.mjs", + "run", + "test/integration", + "test/unit", + "test/components", + "test/services", + "test/lighthouse-config.test.js", + "test/security", + "--config", + "/dev/shm/relay17-rf297-acceptance-evidence/vitest.config.mjs", + "--maxWorkers=2", + "--reporter=default", + "--reporter=json", + "--outputFile.json=/dev/shm/relay17-rf297-acceptance-evidence/vitest-results.json" + ], + "tracked_source_files_verified_before_and_after": 344, + "parent": "aa146d6c8280554cf95849f9912e8a9c1d21baf2", + "counts": { + "numPassedTests": 477, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "numTotalTests": 477, + "numPassedTestSuites": 138, + "numFailedTestSuites": 0, + "numTotalTestSuites": 138, + "success": true + }, + "files": 49 + }, + "format": { + "prettier_version": "3.9.6", + "started_at_utc": "2026-10-04T11:54:03.472640+00:00", + "elapsed_seconds": 9.725404980999883, + "initial_exit_code": 1, + "flagged_files": [ + "src/context/AppContext.jsx", + "src/hooks/useTransfers.js", + "src/pages/SendMoney.jsx", + "src/services/errors.js", + "test/components/WalletButton.test.jsx", + "test/unit/AppContext.wallet.test.jsx", + "test/unit/transfer-snapshot.test.js", + "test/unit/useTransfers.errors.test.jsx", + "vite-plugin-security-headers.js" + ], + "final_exit_code": 0 + }, + "build": { + "command": ["npm", "run", "build"], + "started_at_utc": "2026-10-04T11:56:29.250298+00:00", + "finished_at_utc": "2026-10-04T11:56:31.558839+00:00", + "elapsed_seconds": 2.3085307589935837, + "exit_code": 0, + "tracked_source_files_verified_before_and_after": 344 + }, + "audit": { + "reused": true, + "freshness": "Actual shared scan started2026-10-04T11:56:36.112260Z, completed11:56:56.026595Z; exact same dependency blobs verified in both source trees. No duplicate scan for PR297.", + "receipt": { + "gate": "audit", + "command": [ + "npm", + "audit", + "--audit-level=critical", + "--json", + "--fetch-retries=0", + "--fetch-timeout=20000" + ], + "started_at": "2026-10-04T11:56:36.112260+00:00", + "finished_at": "2026-10-04T11:56:56.026595+00:00", + "elapsed_seconds": 19.914345624994894, + "exit_code": 0, + "vulnerabilities": { + "info": 0, + "low": 2, + "moderate": 12, + "high": 20, + "critical": 0, + "total": 34 + }, + "dependency_file_blobs": { + "package.json": "625d0f3895fcc7afbfcfa131713249f5cd65dcd6", + "package-lock.json": "70d29c1865dac7ce76ba7d4e6ca5ee454be54aae" + }, + "audit_results_sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8", + "donor": "Relay-17 root PR298 validation; only the identical-dependency audit is reused here. Its application tests/build are not claimed for PR297.", + "donor_run_receipt_sha256": "33459b9b6a670e5c669892ca6ba3c091da8c5a18e31e8f76fe215163653a4912" + } + }, + "hosted_ci": "Not executed by this local validation; no hosted CI or Lighthouse pass is asserted.", + "limits": [ + "Real mounted App/jsdom and repository demo transfer/wallet services; no live transfer, signing or settlement.", + "Existing warning output retained; warning-free output is not claimed.", + "No full-scope baseline rerun. Reused four fixture postimages only after their original blobs matched.", + "No retries, omitted test paths, timeout changes, test deletions, snapshot changes or skipped tests in the full candidate run." + ], + "archive": { + "path": "evidence.zip", + "bytes": 60612, + "sha256": "838969807560e6854a77c084694bf730e13ecd792046d2ad9d82860a358ec450", + "members": [ + { + "path": "vitest-results.json", + "bytes": 142433, + "sha256": "95ffc022b0702cae641dc1c5c612f50c5e7d0717aba0081ae6904aa45b2a6223" + }, + { + "path": "vitest-output.log", + "bytes": 38834, + "sha256": "6e258ca806607671336760b14b0466a8133cc1de8056ef727d1f40c4cec1ea73" + }, + { + "path": "test-receipt.json", + "bytes": 1027, + "sha256": "8caaf31caaf5eacfc632f1a6e86e4636e783f089665b8f610226479c3928fa21" + }, + { + "path": "build.log", + "bytes": 477, + "sha256": "d95016ff9d6724cde00d29df509a6c8787021c8f45e73ad0686ee7042b92cf53" + }, + { + "path": "build-receipt.json", + "bytes": 285, + "sha256": "28db2b959784083634ff81c95023eaa54d0da6a773cb6f1470aeec8de324884d" + }, + { + "path": "prettier-before.log", + "bytes": 445, + "sha256": "ce9de8d8a0f050253fbc580d91017031383c98b944490c672264b15519895a94" + }, + { + "path": "prettier-after.log", + "bytes": 66, + "sha256": "17aa973d3f004560237d9a95171210b0671deff23d61628eecf7322ff5938f20" + }, + { + "path": "prettier-write.log", + "bytes": 325, + "sha256": "aefb3d273dc605d2e86d9602a19c26ff370b13a795bd2be58ba6dca1ab1ce11d" + }, + { + "path": "prettier-receipt.json", + "bytes": 549, + "sha256": "cad1d322440ada327df185872fc87540eb75e276123f5a930a3fb34b64f0398d" + }, + { + "path": "reused-audit-receipt.json", + "bytes": 966, + "sha256": "8e92a18bfabc6fcbf494d8077e4cb5bfc488c28c0d70254f407560c21c4d0f38" + }, + { + "path": "reused-audit-results.json", + "bytes": 51078, + "sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8" + }, + { + "path": "composition-manifest.json", + "bytes": 49253, + "sha256": "513e38865f6c339b1513cc82929acd2605175a5583e09420e26616304c29a28f" + }, + { + "path": "candidate-manifest.json", + "bytes": 50707, + "sha256": "28c6d888a6d0ced52d8fb3c03806fd7d37dbbfaac8d3826a1333fbbd749e2f27" + }, + { + "path": "vitest.config.mjs", + "bytes": 168, + "sha256": "0fe9527c1843128db0977063b723df8f03af52e367fa62379add96bc45dc22cf" + }, + { + "path": "run-gates.py", + "bytes": 1875, + "sha256": "87f7d6cacbad3360b6f992fd1e8b53b8dc43ad30b75cafe8419e66a20814d1ce" + }, + { + "path": "audit-donor-run-receipt.json", + "bytes": 1653, + "sha256": "33459b9b6a670e5c669892ca6ba3c091da8c5a18e31e8f76fe215163653a4912" + } + ] + } +} diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 336af1b..452bf9f 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,4 +1,3 @@ - import { createContext, useContext, useEffect, useState } from 'react'; import { connectWallet, diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..80160bb 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,4 +1,3 @@ - import { useCallback, useEffect, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index c1e03f0..9c9a906 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -9,7 +9,11 @@ import Modal from '../components/Modal.jsx'; import { buildQuote } from '../services/quote.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; -import { formatAmount, formatCurrencyInput, parseCurrencyInput } from '../utils/format.js'; +import { + formatAmount, + formatCurrencyInput, + parseCurrencyInput, +} from '../utils/format.js'; import { isPositiveAmount, validateRecipient, @@ -167,7 +171,9 @@ export default function SendMoney() { } const finalQuote = buildQuote(parsedAmount.value, from, to); if (!finalQuote) { - applyErrors({ amount: 'Enter an amount greater than zero.' }); + setSubmitError( + 'We could not price this transfer. Check the amount and currencies.', + ); return; } @@ -193,12 +199,16 @@ export default function SendMoney() { // Rebuild at confirmation time so the committed amounts match the note: // rates are indicative and update at confirmation. - const parsedAmount = parseCurrencyInput(amount, { currency: from, locale }); + const parsedAmount = parseCurrencyInput(amount, { + currency: from, + locale, + }); if (!parsedAmount.ok) { setSubmitError(parsedAmount.error); return; } - const finalQuote = pendingQuote ?? buildQuote(parsedAmount.value, from, to); + const finalQuote = + pendingQuote ?? buildQuote(parsedAmount.value, from, to); if (!finalQuote) { setSubmitError( 'We could not price this transfer. Check the amount and the selected currencies.', @@ -235,18 +245,19 @@ export default function SendMoney() { ); } else { const normalized = normalizeError(err, { source: 'api' }); - // A transfer can be interrupted mid-signature by a connection drop. - // The honest message here is "unknown", not "failed": the backend may - // have accepted the transfer even though the response never arrived. - // The transfers page reconciles real status on reconnect. - // Read the current connectivity directly (not from the render closure) - // so that a mid-flight disconnect produces the correct message. - const connectedNow = typeof navigator !== 'undefined' && navigator.onLine; - setSubmitError( - connectedNow - ? getUserErrorMessage(normalized) - : 'Connection lost while sending. Reconnect to check your transfer status.', - ); + // A transfer can be interrupted mid-signature by a connection drop. + // The honest message here is "unknown", not "failed": the backend may + // have accepted the transfer even though the response never arrived. + // The transfers page reconciles real status on reconnect. + // Read the current connectivity directly (not from the render closure) + // so that a mid-flight disconnect produces the correct message. + const connectedNow = + typeof navigator !== 'undefined' && navigator.onLine; + setSubmitError( + connectedNow + ? getUserErrorMessage(normalized) + : 'Connection lost while sending. Reconnect to check your transfer status.', + ); } } finally { submissionLock.current = false; @@ -292,8 +303,8 @@ export default function SendMoney() { role="status" aria-live="polite" > - ✓ Back online. Your form was not submitted while you were - offline — review it and send when ready. + ✓ Back online. Your form was not submitted while you were offline + — review it and send when ready. )} diff --git a/src/pages/Transfers.css b/src/pages/Transfers.css index fd86757..298167c 100644 --- a/src/pages/Transfers.css +++ b/src/pages/Transfers.css @@ -101,3 +101,12 @@ margin-top: 0; } } + +.transfers-snapshot-notice { + margin: 0 0 1rem; + padding: 0.65rem 0.85rem; + border-radius: 8px; + background: rgba(37, 99, 235, 0.12); + color: var(--color-text); + font-size: 0.9rem; +} diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index def8104..d1d765c 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -19,6 +19,11 @@ import { useTransfers } from '../hooks/useTransfers.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; +import { + DEFAULT_PAGE_SIZE, + encodeCursor, + resolveTransferPage, +} from '../utils/transferSnapshot.js'; import './Transfers.css'; // Derived from the contract so a new lifecycle state cannot be filterable in @@ -31,11 +36,13 @@ const STATUS_OPTIONS = [ })), ]; -const PAGE_SIZE = 5; - /** * Transfers page: lists all transfers with their status. * Filter state is synced to the URL query string. + * + * Pagination uses a frozen snapshot so concurrent inserts cannot duplicate or + * omit rows while the user pages. Filter changes reset the snapshot; expired + * cursors recover by rebuilding from the live filtered list. */ export default function Transfers() { const { transfers, loading, error, reload } = useTransfers(); @@ -57,23 +64,24 @@ export default function Transfers() { const [selectedIds, setSelectedIds] = useState(() => new Set()); const [selectAllAcross, setSelectAllAcross] = useState(false); - // Pagination state - const [page, setPage] = useState(1); - - // Reset page and selection when filters change + // Snapshot pagination state + const [snapshot, setSnapshot] = useState(null); + const [cursor, setCursor] = useState(null); + const [snapshotNotice, setSnapshotNotice] = useState(null); + // Reset cursor/selection when filters change; snapshot rebuilds below. useEffect(() => { - setPage(1); + setCursor(null); + setSnapshot(null); setSelectedIds(new Set()); setSelectAllAcross(false); + setSnapshotNotice(null); }, [search, status, range]); // Normalise the query-string status so a legacy or provider spelling in a // shared/bookmarked URL (?status=settled) still selects the right rows. const canonicalStatus = normalizeStatus(status); + // Track connectivity so that a reconnect triggers an automatic reload. - // The reload reconciles the true status of transfers that may have been - // created or settled while the connection was down — without resubmitting - // anything. useEffect(() => { if (isOnline && wasOffline && !loading) { setSyncingAfterReconnect(true); @@ -94,11 +102,84 @@ export default function Transfers() { }); }, [transfers, search, status, canonicalStatus, range]); - // Paginated data - const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1; - const pageTransfers = useMemo(() => { - return filteredTransfers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - }, [filteredTransfers, page]); + const filterState = useMemo( + () => ({ search, status: canonicalStatus || status, range }), + [search, status, canonicalStatus, range], + ); + + // Resolve the current page against a frozen snapshot. Membership stays + // deterministic even if `transfers` grows while the user is on page 2. + const resolved = useMemo(() => { + if (loading) { + return { + snapshot: null, + page: { + ok: true, + items: [], + page: 1, + totalPages: 1, + totalCount: 0, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: false, + }, + recovered: false, + }; + } + return resolveTransferPage(filteredTransfers, { + filters: filterState, + liveTransfers: transfers, + cursor, + snapshot, + pageSize: DEFAULT_PAGE_SIZE, + }); + }, [filteredTransfers, filterState, transfers, cursor, snapshot, loading]); + + const pageTransfers = resolved.page.items; + const totalPages = resolved.page.totalPages; + // Empty and selection states follow the same frozen membership as the rows. + const totalCount = resolved.page.totalCount; + const page = resolved.page.page; + + // Persist a newly minted snapshot and surface recovery after an expired cursor. + useEffect(() => { + if (resolved.snapshot && resolved.snapshot.id !== snapshot?.id) { + if (snapshot) { + // Selection belongs to the previous frozen membership. Recovery must + // not select newcomers or keep counting rows that have disappeared. + setSelectedIds(new Set()); + setSelectAllAcross(false); + } + setSnapshot(resolved.snapshot); + } + if (resolved.recovered && cursor) { + setCursor(null); + setSnapshotNotice( + 'Transfer list was refreshed to keep paging consistent.', + ); + } + }, [resolved, snapshot, cursor]); + + const handlePageChange = useCallback( + (nextPage) => { + const active = resolved.snapshot; + if (!active) return; + const safePage = Math.min(Math.max(1, nextPage), totalPages); + const priorRef = + active.items[(safePage - 1) * active.pageSize - 1] ?? null; + setCursor( + encodeCursor({ + snapshotId: active.id, + scope: active.scope, + page: safePage, + after: priorRef, + }), + ); + setSnapshotNotice(null); + }, + [resolved.snapshot, totalPages], + ); // Selection state computations const allPageSelected = @@ -107,9 +188,7 @@ export default function Transfers() { const somePageSelected = pageTransfers.some((t) => selectAllAcross ? true : selectedIds.has(t.id), ); - const selectedCount = selectAllAcross - ? filteredTransfers.length - : selectedIds.size; + const selectedCount = selectAllAcross ? totalCount : selectedIds.size; const hasMorePages = totalPages > 1; const handleSearchChange = useCallback( @@ -151,20 +230,28 @@ export default function Transfers() { const hasActiveFilters = Boolean(search || status || range); // Selection handlers - const handleToggleSelect = useCallback((id) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - setSelectAllAcross(false); - }, []); + const handleToggleSelect = useCallback( + (id) => { + setSelectedIds((prev) => { + // Across-page selection is implicit until the first deselection. + const next = new Set( + selectAllAcross ? resolved.snapshot.items.map((t) => t.id) : prev, + ); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + setSelectAllAcross(false); + }, + [selectAllAcross, resolved.snapshot], + ); const handleTogglePage = useCallback(() => { if (allPageSelected) { setSelectedIds((prev) => { - const next = new Set(prev); + const next = new Set( + selectAllAcross ? resolved.snapshot.items.map((t) => t.id) : prev, + ); pageTransfers.forEach((t) => next.delete(t.id)); return next; }); @@ -176,7 +263,7 @@ export default function Transfers() { }); } setSelectAllAcross(false); - }, [allPageSelected, pageTransfers]); + }, [allPageSelected, pageTransfers, selectAllAcross, resolved.snapshot]); const handleSelectAllAcross = useCallback(() => { setSelectAllAcross(true); @@ -201,7 +288,7 @@ export default function Transfers() { return ; } - if (filteredTransfers.length === 0) { + if (totalCount === 0) { return ( ))} - + ); }; @@ -283,6 +374,17 @@ export default function Transfers() { )} + {snapshotNotice && ( +
+ {snapshotNotice} +
+ )} +
+ record === undefined + ? undefined + : { + record, + time: Date.parse(record?.createdAt ?? '') || 0, + id: String(record?.id ?? ''), + }, + ) + .sort((a, b) => b.time - a.time || b.id.localeCompare(a.id)) + .map((entry) => entry?.record); +} + +/** + * Canonical filter scope bound into every cursor. + * @param {{search?: string, status?: string, range?: string}} filters + */ +export function filterScopeKey(filters = {}) { + return JSON.stringify({ + search: String(filters.search ?? ''), + status: String(filters.status ?? ''), + range: String(filters.range ?? ''), + }); +} + +/** + * @param {object[]} transfers - already filtered live rows + * @param {{filters?: object, now?: number|Date, pageSize?: number, snapshotId?: string}} [options] + */ +export function createTransferSnapshot(transfers, options = {}) { + const nowMs = + options.now instanceof Date + ? options.now.getTime() + : (options.now ?? Date.now()); + const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + const scope = filterScopeKey(options.filters); + const items = stableSortTransfers(transfers).map((t) => ({ + id: t.id, + createdAt: t.createdAt, + record: { ...t }, + })); + + return { + id: options.snapshotId ?? mintSnapshotId(nowMs, scope, items.length), + createdAt: new Date(nowMs).toISOString(), + expiresAt: new Date(nowMs + SNAPSHOT_TTL_MS).toISOString(), + scope, + pageSize, + items, + }; +} + +// A snapshot is one in-memory generation, not just a time/filter/count tuple. +// Rapid replacements can share all three, so keep their cursors distinct even +// when the clock is fixed or moves backward. Explicit caller IDs stay unchanged. +let snapshotGeneration = 0; + +function mintSnapshotId(nowMs, scope, count) { + const seed = `${nowMs}:${scope}:${count}`; + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i += 1) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + snapshotGeneration += 1; + return `snap_${(hash >>> 0).toString(16).padStart(8, '0')}_${snapshotGeneration.toString(36)}`; +} + +/** + * Opaque cursor: snapshot id + scope + last-seen key + page index. + * @param {{snapshotId: string, scope: string, after?: {createdAt: string, id: string}|null, page: number}} parts + */ +export function encodeCursor(parts) { + const payload = { + s: parts.snapshotId, + scope: parts.scope, + page: parts.page, + after: parts.after ? { t: parts.after.createdAt, i: parts.after.id } : null, + }; + return `c_${toBase64Url(JSON.stringify(payload))}`; +} + +/** + * @param {string} cursor + * @returns {{snapshotId: string, scope: string, page: number, after: {createdAt: string, id: string}|null}|null} + */ +export function decodeCursor(cursor) { + if (!cursor || typeof cursor !== 'string' || !cursor.startsWith('c_')) { + return null; + } + try { + const raw = JSON.parse(fromBase64Url(cursor.slice(2))); + if (!raw?.s || !Number.isSafeInteger(raw.page) || raw.page < 1) return null; + return { + snapshotId: raw.s, + scope: raw.scope ?? '', + page: raw.page, + after: raw.after ? { createdAt: raw.after.t, id: raw.after.i } : null, + }; + } catch { + return null; + } +} + +function toBase64Url(value) { + if (typeof btoa === 'function') { + return btoa(unescape(encodeURIComponent(value))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + return Buffer.from(value, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function fromBase64Url(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const pad = + padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + const b64 = padded + pad; + if (typeof atob === 'function') { + return decodeURIComponent(escape(atob(b64))); + } + return Buffer.from(b64, 'base64').toString('utf8'); +} + +/** + * Is the cursor still valid for this snapshot and filter scope? + * @param {object|null} decoded + * @param {object} snapshot + * @param {object} filters + * @param {number|Date} [now] + */ +export function isCursorInScope(decoded, snapshot, filters, now = Date.now()) { + if (!decoded || !snapshot) return false; + if (decoded.snapshotId !== snapshot.id) return false; + if (decoded.scope !== snapshot.scope) return false; + if (decoded.scope !== filterScopeKey(filters)) return false; + if (isSnapshotExpired(snapshot, now)) return false; + const totalPages = Math.max( + 1, + Math.ceil(snapshot.items.length / snapshot.pageSize), + ); + if (decoded.page > totalPages) return false; + const prior = + snapshot.items[(decoded.page - 1) * snapshot.pageSize - 1] ?? null; + if (!prior) return decoded.after === null; + return ( + decoded.after?.id === prior.id && + decoded.after?.createdAt === prior.createdAt + ); +} + +export function isSnapshotExpired(snapshot, now = Date.now()) { + if (!snapshot?.expiresAt) return true; + const expiry = Date.parse(snapshot.expiresAt); + if (Number.isNaN(expiry)) return true; + const current = now instanceof Date ? now.getTime() : now; + return current >= expiry; +} + +/** + * Page a snapshot. Returns rows from the live transfer map so callers still + * render full records, but membership is frozen to the snapshot. + * + * @param {object} snapshot + * @param {object[]} liveTransfers - full transfer objects (id-keyed lookup) + * @param {{cursor?: string|null, filters?: object, now?: number|Date}} [options] + */ +export function pageFromSnapshot(snapshot, liveTransfers, options = {}) { + const filters = options.filters ?? {}; + const now = options.now ?? Date.now(); + const pageSize = snapshot?.pageSize ?? DEFAULT_PAGE_SIZE; + + const decoded = options.cursor ? decodeCursor(options.cursor) : null; + const scoped = isCursorInScope(decoded, snapshot, filters, now); + + if (!snapshot || isSnapshotExpired(snapshot, now)) { + return { + ok: false, + code: 'snapshot_expired', + items: [], + page: 1, + totalPages: 1, + totalCount: 0, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: true, + }; + } + + if (options.cursor && !scoped) { + return { + ok: false, + code: 'cursor_expired', + items: [], + page: 1, + totalPages: Math.max(1, Math.ceil(snapshot.items.length / pageSize)), + totalCount: snapshot.items.length, + cursor: null, + nextCursor: null, + prevCursor: null, + recovered: true, + }; + } + + const page = scoped ? Math.max(1, decoded.page) : 1; + const totalCount = snapshot.items.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize) || 1); + const start = (page - 1) * pageSize; + const slice = snapshot.items.slice(start, start + pageSize); + // Only visible snapshot IDs need fresh fields. Walk arrays backward so the + // first match is the last duplicate, as in Map(rows), and stop once every + // visible ID is resolved. Missing IDs still require the complete scan. + const pageIds = new Set(slice.map((ref) => ref.id)); + const byId = new Map(); + if ( + Array.isArray(liveTransfers) && + liveTransfers[Symbol.iterator] === Array.prototype[Symbol.iterator] + ) { + for (let i = liveTransfers.length - 1; i >= 0 && pageIds.size > 0; i -= 1) { + const transfer = liveTransfers[i]; + if (pageIds.delete(transfer.id)) byId.set(transfer.id, transfer); + } + } else if (pageIds.size > 0) { + // Keep the existing forward-only iterable contract and duplicate policy. + for (const transfer of liveTransfers ?? []) { + if (pageIds.has(transfer.id)) byId.set(transfer.id, transfer); + } + } + // Keep a complete snapshot even if a row disappears or changes its active + // filter after the snapshot was taken. Fresh fields win while it remains live. + const items = slice.map((ref) => byId.get(ref.id) ?? ref.record); + const priorRef = snapshot.items[start - 1] ?? null; + const lastRef = slice[slice.length - 1] ?? null; + const currentCursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page, + after: priorRef, + }); + const nextCursor = + page < totalPages + ? encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: page + 1, + after: lastRef, + }) + : null; + const prevCursor = + page > 1 + ? encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: page - 1, + after: snapshot.items[(page - 2) * pageSize - 1] ?? null, + }) + : null; + + return { + ok: true, + code: null, + items, + page, + totalPages, + totalCount, + cursor: currentCursor, + nextCursor, + prevCursor, + recovered: false, + }; +} + +/** + * Resolve a page, recovering from expired cursors by minting a fresh snapshot. + * @param {object[]} filteredTransfers + * @param {{ + * filters: object, + * cursor?: string|null, + * snapshot?: object|null, + * pageSize?: number, + * now?: number|Date, + * liveTransfers?: object[], + * }} state + */ +export function resolveTransferPage(filteredTransfers, state = {}) { + const now = state.now ?? Date.now(); + const filters = state.filters ?? {}; + // Filters choose snapshot membership; the complete current list supplies + // fresh fields even when a retained row no longer matches those filters. + const liveTransfers = state.liveTransfers ?? filteredTransfers; + let snapshot = state.snapshot ?? null; + let recovered = false; + + // An empty snapshot created while the list was still loading must not freeze + // out rows that arrive a moment later. Concurrent inserts into a non-empty + // snapshot remain frozen (that is the whole point of snapshot paging). + const needsNew = + !snapshot || + isSnapshotExpired(snapshot, now) || + snapshot.scope !== filterScopeKey(filters) || + (snapshot.items.length === 0 && (filteredTransfers?.length ?? 0) > 0); + + if (needsNew) { + snapshot = createTransferSnapshot(filteredTransfers, { + filters, + now, + pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE, + }); + recovered = Boolean(state.snapshot) || Boolean(state.cursor); + } + + const page = pageFromSnapshot(snapshot, liveTransfers, { + cursor: needsNew ? null : state.cursor, + filters, + now, + }); + + if (!page.ok) { + snapshot = createTransferSnapshot(filteredTransfers, { + filters, + now, + pageSize: state.pageSize ?? DEFAULT_PAGE_SIZE, + }); + const recoveredPage = pageFromSnapshot(snapshot, liveTransfers, { + cursor: null, + filters, + now, + }); + return { + snapshot, + page: { ...recoveredPage, recovered: true }, + recovered: true, + }; + } + + return { snapshot, page: { ...page, recovered }, recovered }; +} diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx index 46287e2..701fc6f 100644 --- a/test/components/WalletButton.test.jsx +++ b/test/components/WalletButton.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -118,7 +117,9 @@ describe('WalletButton', () => { ); await waitFor(() => { - expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/something went wrong/i), + ).not.toBeInTheDocument(); expect(screen.getByText(/500 XLM/)).toBeInTheDocument(); }); }); diff --git a/test/integration/offline-reconnect.test.jsx b/test/integration/offline-reconnect.test.jsx index 10a8761..a6de581 100644 --- a/test/integration/offline-reconnect.test.jsx +++ b/test/integration/offline-reconnect.test.jsx @@ -1,4 +1,11 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../../src/App.jsx'; @@ -43,6 +50,8 @@ describe('Offline and reconnect state for transfer mutations', () => { }); window.history.pushState({}, '', '/send'); localStorage.clear(); + // These cases exercise connectivity, not the demo wallet's random refusal. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -80,9 +89,7 @@ describe('Offline and reconnect state for transfer mutations', () => { goOffline(); expect( - await screen.findByText( - /you're offline\. some features may not work/i, - ), + await screen.findByText(/you're offline\. some features may not work/i), ).toBeInTheDocument(); }); @@ -119,14 +126,38 @@ describe('Offline and reconnect state for transfer mutations', () => { goOnline(); // After reconnecting, the button should change back to "Review & Send" and be enabled. - const submitButton = await screen.findByRole('button', { - name: /review & send/i, - }, { timeout: 5000 }); + const submitButton = await screen.findByRole( + 'button', + { + name: /review & send/i, + }, + { timeout: 5000 }, + ); expect(submitButton).toBeEnabled(); + expect(createTransfer).not.toHaveBeenCalled(); await user.click(submitButton); - - await screen.findByRole('heading', { name: /your transfers/i }, { timeout: 10000 }); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); + const result = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(result).getByRole('button', { name: /view transfers/i }), + ); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 10000 }, + ); expect(createTransfer).toHaveBeenCalledTimes(1); }); @@ -170,9 +201,15 @@ describe('Offline and reconnect state for transfer mutations', () => { await fillValidForm(user); - // Start the submission. - const submitButton = screen.getByRole('button', { name: /review & send/i }); - fireEvent.click(submitButton); + // Review first, then dispatch through the actual confirmation dialog. + await user.click(screen.getByRole('button', { name: /review & send/i })); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); // After the catch block runs, the error message should be about // connection loss, NOT a generic "could not submit" message. @@ -185,5 +222,6 @@ describe('Offline and reconnect state for transfer mutations', () => { expect( screen.queryByText(/could not submit the transfer\./i), ).not.toBeInTheDocument(); + expect(createTransfer).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/test/integration/select-all-across-pages.test.jsx b/test/integration/select-all-across-pages.test.jsx index 90daea1..f030d29 100644 --- a/test/integration/select-all-across-pages.test.jsx +++ b/test/integration/select-all-across-pages.test.jsx @@ -112,4 +112,49 @@ describe('Select all across pages', () => { screen.queryByRole('navigation', { name: /pagination/i }), ).not.toBeInTheDocument(); }); + + it.each([ + { target: 'row', remaining: 6 }, + { target: 'page', remaining: 5 }, + ])( + 'deselecting a $target after selecting all keeps other pages selected', + async ({ target, remaining }) => { + const user = userEvent.setup(); + seedTransfers(7); + await gotoTransfers(); + await user.click( + screen.getByLabelText(/select all transfers on this page/i), + ); + await user.click( + screen.getByRole('button', { name: /select all 7 transfers/i }), + ); + await user.click(screen.getByRole('button', { name: /next/i })); + + const pageRows = screen.getAllByRole('checkbox', { + name: /select transfer to/i, + }); + expect(pageRows).toHaveLength(2); + await user.click( + target === 'row' + ? pageRows[0] + : screen.getByLabelText(/select all transfers on this page/i), + ); + expect(pageRows[0]).not.toBeChecked(); + if (target === 'row') expect(pageRows[1]).toBeChecked(); + else expect(pageRows[1]).not.toBeChecked(); + expect( + screen.getByText(`${remaining} transfers selected`), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /previous/i })); + const otherPageRows = screen.getAllByRole('checkbox', { + name: /select transfer to/i, + }); + expect(otherPageRows).toHaveLength(5); + otherPageRows.forEach((checkbox) => expect(checkbox).toBeChecked()); + expect( + screen.getByText(`${remaining} transfers selected`), + ).toBeInTheDocument(); + }, + ); }); diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 43a7381..9ae992d 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -1,4 +1,3 @@ - import { act, fireEvent, @@ -61,6 +60,8 @@ describe('Send money form flows', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); localStorage.clear(); + // Keep the mock wallet's random rejection out of submission-guard tests. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -113,7 +114,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled(); + expect( + within(dialog).getByRole('button', { name: /sending/i }), + ).toBeDisabled(); expect(createTransfer).not.toHaveBeenCalled(); const resultDialog = await screen.findByRole( @@ -200,7 +203,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument(); + expect( + await screen.findByText(/something went wrong/i), + ).toBeInTheDocument(); const retryButton = screen.getByRole('button', { name: /review & send/i }); expect(retryButton).toBeEnabled(); diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx index f5cf076..136b66c 100644 --- a/test/integration/send-money-precision.test.jsx +++ b/test/integration/send-money-precision.test.jsx @@ -22,6 +22,32 @@ async function fillForm(user, { amount, to = 'NGN' } = {}) { return amountField; } +async function reviewAndConfirm(user) { + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); +} + +async function viewSubmittedTransfer(user) { + const dialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(dialog).getByRole('button', { name: /view transfers/i }), + ); + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); +} + describe('Send flow — amount parsing regressions', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); @@ -44,17 +70,22 @@ describe('Send flow — amount parsing regressions', () => { }); it('keeps a negative amount negative so validation can reject it', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); // "-5" used to be silently rewritten to "5.00" and sent as a real transfer. await fillForm(user, { amount: '-5' }); - expect(screen.getByLabelText(/^amount$/i)).toHaveValue(-5); + expect(screen.getByLabelText(/^amount$/i)).toHaveValue('-5.00'); await user.click(screen.getByRole('button', { name: /review & send/i })); expect( - await screen.findByText(/enter an amount greater than zero/i), + await screen.findByText(/amount cannot be negative/i), ).toBeInTheDocument(); + expect(createTransfer).not.toHaveBeenCalled(); + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); }); it('submits the exponent amount that the field actually held', async () => { @@ -63,13 +94,8 @@ describe('Send flow — amount parsing regressions', () => { render(); await fillForm(user, { amount: '1e3' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); expect(createTransfer).toHaveBeenCalledTimes(1); expect(createTransfer.mock.calls[0][0]).toMatchObject({ sendAmount: '1000', @@ -96,13 +122,8 @@ describe('Send flow — the receipt matches the quote', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // 200.00 - 1.10 fee = 198.90, at 1480.5 NGN/USD = 294471.45 exactly. const payload = createTransfer.mock.calls[0][0]; @@ -141,12 +162,8 @@ describe('Send flow — the receipt matches the quote', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // The number on the receipt is the number that was quoted, to the cent. // The seeded demo transfer shares this recipient, so find the row by the @@ -205,7 +222,7 @@ describe('Send flow — contract failures do not submit', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); + await reviewAndConfirm(user); expect( await screen.findByText(/rejected before it was sent/i, undefined, { diff --git a/test/integration/transfers-snapshot-pagination.test.jsx b/test/integration/transfers-snapshot-pagination.test.jsx new file mode 100644 index 0000000..fbb7f2c --- /dev/null +++ b/test/integration/transfers-snapshot-pagination.test.jsx @@ -0,0 +1,270 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import { SNAPSHOT_TTL_MS } from '../../src/utils/transferSnapshot.js'; + +function seedTransfers(count) { + const transfers = Array.from({ length: count }, (_, i) => ({ + id: `tx_${1000 + i}`, + recipient: `person${i}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 100 + i, + receiveAmount: (100 + i) * 1400, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: `2026-06-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + })); + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + return transfers; +} + +async function gotoTransfers() { + window.history.pushState({}, '', '/transfers'); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + await screen.findByLabelText(/select all transfers on this page/i); +} + +describe('Transfers snapshot pagination', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('pages through a frozen snapshot without gaps', async () => { + const user = userEvent.setup(); + seedTransfers(12); + await gotoTransfers(); + + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument(); + + // Newest by createdAt: person11 is June 12. + expect( + screen.getByRole('group', { name: /transfer to person11@e/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to person6@e/i }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/page 2 of 3/i)).toBeInTheDocument(); + expect( + screen.getByRole('group', { name: /transfer to person6@e/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to person11@e/i }), + ).not.toBeInTheDocument(); + }); + + it('resets to page 1 when filters change', async () => { + const user = userEvent.setup(); + seedTransfers(12); + await gotoTransfers(); + + await user.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/page 2 of /i)).toBeInTheDocument(); + + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + + expect(await screen.findByText(/page 1 of /i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); + }); + + it('refreshes a settled transfer while retaining the pending snapshot membership', async () => { + const user = userEvent.setup(); + const transfers = seedTransfers(12); + await gotoTransfers(); + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + + const rowName = /transfer to person11@e/i; + expect( + within(screen.getByRole('group', { name: rowName })).getByText('Pending'), + ).toBeInTheDocument(); + expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument(); + + transfers[11].status = 'completed'; + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + await user.click(screen.getByRole('button', { name: /refresh list/i })); + + const refreshedRow = await screen.findByRole('group', { name: rowName }); + expect(within(refreshedRow).getByText('Completed')).toBeInTheDocument(); + expect(within(refreshedRow).queryByText('Pending')).not.toBeInTheDocument(); + expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument(); + }); + + it('retains all settled snapshot rows and selection totals until filters reset', async () => { + const user = userEvent.setup(); + const transfers = seedTransfers(12); + await gotoTransfers(); + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + await user.click( + screen.getByLabelText(/select all transfers on this page/i), + ); + await user.click( + screen.getByRole('button', { name: 'Select all 6 transfers' }), + ); + + transfers.forEach((transfer) => { + if (transfer.status === 'pending') transfer.status = 'completed'; + }); + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + await user.click(screen.getByRole('button', { name: /refresh list/i })); + + const newest = await screen.findByRole('group', { + name: /transfer to person11@e/i, + }); + expect(within(newest).getByText('Completed')).toBeInTheDocument(); + expect(screen.queryByText('No matching transfers')).not.toBeInTheDocument(); + expect( + screen.getAllByRole('group', { name: /transfer to /i }), + ).toHaveLength(5); + expect(screen.getByText('6 transfers selected')).toBeInTheDocument(); + expect( + screen.getByText('All 6 transfers are selected.'), + ).toBeInTheDocument(); + expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /next/i })); + const oldest = await screen.findByRole('group', { + name: /transfer to person1@e/i, + }); + expect(within(oldest).getByText('Completed')).toBeInTheDocument(); + expect( + screen.getAllByRole('group', { name: /transfer to /i }), + ).toHaveLength(1); + expect(screen.getByText(/page 2 of 2/i)).toBeInTheDocument(); + expect(screen.getByText('6 transfers selected')).toBeInTheDocument(); + + // An explicit filter reset creates a new, genuinely empty pending snapshot. + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'completed', + ); + await screen.findByRole('group', { name: /transfer to person11@e/i }); + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + expect( + await screen.findByText('No matching transfers'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to /i }), + ).not.toBeInTheDocument(); + }); + + it('keeps selection totals scoped to the snapshot after a concurrent insert', async () => { + const user = userEvent.setup(); + const transfers = seedTransfers(12); + await gotoTransfers(); + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'pending', + ); + await user.click( + screen.getByLabelText(/select all transfers on this page/i), + ); + await user.click( + screen.getByRole('button', { name: 'Select all 6 transfers' }), + ); + + transfers.push({ + ...transfers[11], + id: 'tx_concurrent', + recipient: 'new@example.com', + createdAt: '2026-06-13T10:00:00Z', + }); + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + await user.click(screen.getByRole('button', { name: /refresh list/i })); + + await screen.findByRole('group', { name: /transfer to person11@e/i }); + expect(screen.getByText('6 transfers selected')).toBeInTheDocument(); + expect( + screen.getByText('All 6 transfers are selected.'), + ).toBeInTheDocument(); + expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument(); + expect( + screen.queryByRole('group', { name: /transfer to new@example.com/i }), + ).not.toBeInTheDocument(); + }); + + it('clears select-all when an expired snapshot includes a new transfer', async () => { + let now = Date.now(); + vi.spyOn(Date, 'now').mockImplementation(() => now); + const user = userEvent.setup(); + const transfers = seedTransfers(12); + await gotoTransfers(); + await user.click( + screen.getByLabelText(/select all transfers on this page/i), + ); + await user.click( + screen.getByRole('button', { name: 'Select all 12 transfers' }), + ); + expect(screen.getByText('12 transfers selected')).toBeInTheDocument(); + + transfers.push({ + ...transfers[11], + id: 'tx_new_after_expiry', + recipient: 'new@example.com', + createdAt: '2026-06-13T10:00:00Z', + }); + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); + now += SNAPSHOT_TTL_MS + 1; + await user.click(screen.getByRole('button', { name: /refresh list/i })); + + const newcomer = await screen.findByRole('group', { + name: /transfer to new@example.com/i, + }); + expect(within(newcomer).getByRole('checkbox')).not.toBeChecked(); + expect( + screen.queryByText(/\d+ transfers? selected/), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText(/select all transfers on this page/i), + ).not.toBeChecked(); + expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument(); + }); + + it('clears individual selections when snapshot recovery removes their rows', async () => { + let now = Date.now(); + vi.spyOn(Date, 'now').mockImplementation(() => now); + const user = userEvent.setup(); + const transfers = seedTransfers(12); + await gotoTransfers(); + const selectedRow = screen.getByRole('group', { + name: /transfer to person11@e/i, + }); + await user.click(within(selectedRow).getByRole('checkbox')); + expect(screen.getByText('1 transfer selected')).toBeInTheDocument(); + + localStorage.setItem( + 'remitflow.transfers', + JSON.stringify(transfers.slice(0, 11)), + ); + now += SNAPSHOT_TTL_MS + 1; + await user.click(screen.getByRole('button', { name: /refresh list/i })); + + await screen.findByRole('group', { name: /transfer to person10@e/i }); + expect( + screen.queryByRole('group', { name: /transfer to person11@e/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('1 transfer selected')).not.toBeInTheDocument(); + for (const row of screen.getAllByRole('group', { name: /transfer to /i })) { + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + } + }); +}); diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx index 20c04bf..4436e1d 100644 --- a/test/unit/AppContext.wallet.test.jsx +++ b/test/unit/AppContext.wallet.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { AppProvider, useApp } from '../../src/context/AppContext.jsx'; diff --git a/test/unit/quote.test.js b/test/unit/quote.test.js index 0fcfe52..9461138 100644 --- a/test/unit/quote.test.js +++ b/test/unit/quote.test.js @@ -4,18 +4,26 @@ import { buildQuote } from '../../src/services/quote.js'; describe('buildQuote', () => { it('keeps submitted, fee, and receipt amounts as canonical decimal strings', () => { const quote = buildQuote('100.10', 'USD', 'NGN'); + // The quote contract removes redundant zeros while preserving exact values. expect(quote).toMatchObject({ - sendAmount: '100.10', - fee: '0.60', - amountAfterFee: '99.50', + sendAmount: '100.1', + fee: '0.6', + amountAfterFee: '99.5', receiveAmount: '147309.75', }); }); it('regresses binary floating-point drift at the receipt boundary', () => { const quote = buildQuote('0.30', 'USD', 'MXN'); - expect(quote.sendAmount).toBe('0.30'); - expect(quote.amountAfterFee).toBe('0.00'); - expect(quote.receiveAmount).toBe('0.00'); + expect(quote.sendAmount).toBe('0.3'); + // The minimum fee is 0.25: 0.05 * 17.1 = 0.855, rounded to 0.86 MXN. + expect(quote.fee).toBe('0.25'); + expect(quote.amountAfterFee).toBe('0.05'); + expect(quote.receiveAmount).toBe('0.86'); + + expect(buildQuote('0.20', 'USD', 'MXN')).toMatchObject({ + amountAfterFee: '0', + receiveAmount: '0', + }); }); }); diff --git a/test/unit/transfer-snapshot-sort.test.js b/test/unit/transfer-snapshot-sort.test.js new file mode 100644 index 0000000..0d8d2a8 --- /dev/null +++ b/test/unit/transfer-snapshot-sort.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { stableSortTransfers } from '../../src/utils/transferSnapshot.js'; + +describe('snapshot sort key preparation', () => { + it('preserves locale ordering, stable ties, sparse slots and row references', () => { + const rows = [ + undefined, + null, + { id: 'old', createdAt: '1960-01-01T00:00:00Z' }, + { id: 'invalid', createdAt: 'not-a-date' }, + ...['é', 'e\u0301', 'a', 'A', '😀', 'same', 'same'].map((id) => + Object.freeze({ id, createdAt: '1970-01-01T00:00:00Z' }), + ), + ]; + rows.length += 1; + Object.freeze(rows); + const expected = rows.slice().sort((a, b) => { + const aTime = Date.parse(a?.createdAt ?? '') || 0; + const bTime = Date.parse(b?.createdAt ?? '') || 0; + if (bTime !== aTime) return bTime - aTime; + return String(b?.id ?? '').localeCompare(String(a?.id ?? '')); + }); + + const sorted = stableSortTransfers(rows); + expect(sorted).toStrictEqual(expected); + expect(Object.keys(sorted)).toEqual(Object.keys(expected)); + sorted.forEach((record, i) => expect(record).toBe(expected[i])); + expect(stableSortTransfers(null)).toEqual([]); + expect(stableSortTransfers([])).toEqual([]); + }); + + it('parses each timestamp once rather than on every comparison', () => { + const rows = Array.from({ length: 128 }, (_, i) => ({ + id: `tx_${i}`, + createdAt: new Date(((i * 37) % 128) * 1000).toISOString(), + })); + const parse = vi.spyOn(Date, 'parse'); + try { + const sorted = stableSortTransfers(rows); + expect(parse).toHaveBeenCalledTimes(rows.length); + expect(sorted).toHaveLength(rows.length); + expect(sorted[0].createdAt).toBe(new Date(127000).toISOString()); + } finally { + parse.mockRestore(); + } + }); +}); diff --git a/test/unit/transfer-snapshot.test.js b/test/unit/transfer-snapshot.test.js new file mode 100644 index 0000000..da9ab4f --- /dev/null +++ b/test/unit/transfer-snapshot.test.js @@ -0,0 +1,355 @@ +import { describe, expect, it } from 'vitest'; +import { + SNAPSHOT_TTL_MS, + createTransferSnapshot, + decodeCursor, + encodeCursor, + filterScopeKey, + isCursorInScope, + pageFromSnapshot, + resolveTransferPage, + stableSortTransfers, +} from '../../src/utils/transferSnapshot.js'; + +const NOW = Date.parse('2026-09-24T19:00:00Z'); + +function tx(id, createdAt, extra = {}) { + return { + id, + recipient: `${id}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: '10.00', + receiveAmount: '1000.00', + status: 'completed', + createdAt, + ...extra, + }; +} + +describe('transferSnapshot', () => { + it('sorts by createdAt desc with id as tie-breaker', () => { + const rows = [ + tx('tx_a', '2026-09-01T10:00:00Z'), + tx('tx_b', '2026-09-01T10:00:00Z'), + tx('tx_c', '2026-09-02T10:00:00Z'), + ]; + expect(stableSortTransfers(rows).map((r) => r.id)).toEqual([ + 'tx_c', + 'tx_b', + 'tx_a', + ]); + }); + + it('keeps pages deterministic when new transfers arrive', () => { + const initial = Array.from({ length: 12 }, (_, i) => + tx( + `tx_${String(i).padStart(2, '0')}`, + `2026-09-01T10:${String(i).padStart(2, '0')}:00Z`, + ), + ); + const snapshot = createTransferSnapshot(initial, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + + const page1 = pageFromSnapshot(snapshot, initial, { + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page1.ok).toBe(true); + expect(page1.items.map((t) => t.id)).toEqual([ + 'tx_11', + 'tx_10', + 'tx_09', + 'tx_08', + 'tx_07', + ]); + + // Concurrent insert of a newer transfer must not push into page 1 of this snapshot. + const withInsert = [tx('tx_new', '2026-09-03T10:00:00Z'), ...initial]; + const page1Again = pageFromSnapshot(snapshot, withInsert, { + cursor: page1.cursor, + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page1Again.items.map((t) => t.id)).toEqual( + page1.items.map((t) => t.id), + ); + expect(page1Again.items.map((t) => t.id)).not.toContain('tx_new'); + + const page2 = pageFromSnapshot(snapshot, withInsert, { + cursor: page1.nextCursor, + filters: { search: '', status: '', range: '' }, + now: NOW, + }); + expect(page2.items.map((t) => t.id)).toEqual([ + 'tx_06', + 'tx_05', + 'tx_04', + 'tx_03', + 'tx_02', + ]); + }); + + it('keeps snapshot pages complete after a live row disappears and rejects a forged cursor', () => { + const rows = Array.from({ length: 7 }, (_, i) => + tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`), + ); + const filters = { search: '', status: '', range: '' }; + const snapshot = createTransferSnapshot(rows, { + filters, + now: NOW, + pageSize: 5, + }); + const first = pageFromSnapshot(snapshot, rows, { filters, now: NOW }); + const filtered = rows.filter((row) => row.id !== 'tx_1'); + const second = pageFromSnapshot(snapshot, filtered, { + cursor: first.nextCursor, + filters, + now: NOW, + }); + expect(second.ok).toBe(true); + expect(second.items.map((row) => row.id)).toEqual(['tx_1', 'tx_0']); + + const forged = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 2, + after: snapshot.items[0], + }); + expect( + pageFromSnapshot(snapshot, filtered, { + cursor: forged, + filters, + now: NOW, + }).code, + ).toBe('cursor_expired'); + }); + + it('rejects cursors outside the filter scope', () => { + const rows = [tx('tx_1', '2026-09-01T10:00:00Z')]; + const snapshot = createTransferSnapshot(rows, { + filters: { search: '', status: 'completed', range: '' }, + now: NOW, + }); + const cursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 1, + after: null, + }); + const decoded = decodeCursor(cursor); + expect( + isCursorInScope( + decoded, + snapshot, + { search: '', status: 'pending', range: '' }, + NOW, + ), + ).toBe(false); + expect( + isCursorInScope( + decoded, + snapshot, + { search: '', status: 'completed', range: '' }, + NOW, + ), + ).toBe(true); + }); + + it('recovers from an expired snapshot cursor', () => { + const rows = Array.from({ length: 6 }, (_, i) => + tx(`tx_${i}`, `2026-09-01T10:0${i}:00Z`), + ); + const snapshot = createTransferSnapshot(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const staleCursor = encodeCursor({ + snapshotId: snapshot.id, + scope: snapshot.scope, + page: 2, + after: snapshot.items[4], + }); + + const resolved = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + cursor: staleCursor, + snapshot, + pageSize: 5, + now: NOW + SNAPSHOT_TTL_MS + 1, + }); + + expect(resolved.recovered).toBe(true); + expect(resolved.page.page).toBe(1); + expect(resolved.snapshot.id).not.toBe(snapshot.id); + expect(resolved.page.items).toHaveLength(5); + }); + + it('resets to a new snapshot when filters change', () => { + const rows = [ + tx('tx_1', '2026-09-01T10:00:00Z', { status: 'completed' }), + tx('tx_2', '2026-09-01T11:00:00Z', { status: 'pending' }), + ]; + const first = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const second = resolveTransferPage( + rows.filter((t) => t.status === 'pending'), + { + filters: { search: '', status: 'pending', range: '' }, + cursor: first.page.cursor, + snapshot: first.snapshot, + now: NOW, + pageSize: 5, + }, + ); + expect(second.snapshot.scope).toBe( + filterScopeKey({ search: '', status: 'pending', range: '' }), + ); + expect(second.snapshot.id).not.toBe(first.snapshot.id); + expect(second.page.items.map((t) => t.id)).toEqual(['tx_2']); + }); + + it('pages a large fixture without gaps inside the snapshot', () => { + const rows = Array.from({ length: 50 }, (_, i) => + tx( + `tx_${String(i).padStart(3, '0')}`, + `2026-08-01T${String(i % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`, + ), + ); + let state = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + const seen = new Set(state.page.items.map((t) => t.id)); + while (state.page.nextCursor) { + state = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + cursor: state.page.nextCursor, + snapshot: state.snapshot, + now: NOW, + pageSize: 5, + }); + for (const item of state.page.items) { + expect(seen.has(item.id)).toBe(false); + seen.add(item.id); + } + } + expect(seen.size).toBe(50); + }); + + it('rebuilds when an empty loading snapshot later receives rows', () => { + const empty = resolveTransferPage([], { + filters: { search: '', status: '', range: '' }, + now: NOW, + pageSize: 5, + }); + expect(empty.snapshot.items).toHaveLength(0); + + const rows = [tx('tx_1', '2026-09-01T10:00:00Z')]; + const filled = resolveTransferPage(rows, { + filters: { search: '', status: '', range: '' }, + snapshot: empty.snapshot, + now: NOW, + pageSize: 5, + }); + expect(filled.snapshot.items).toHaveLength(1); + expect(filled.page.items.map((t) => t.id)).toEqual(['tx_1']); + }); + + it('refreshes live fields without replacing membership when a row stops matching the filter', () => { + const initial = [ + tx('tx_1', '2026-09-01T10:00:00Z', { status: 'pending' }), + tx('tx_2', '2026-09-01T11:00:00Z', { status: 'pending' }), + ]; + const filters = { search: '', status: 'pending', range: '' }; + const first = resolveTransferPage(initial, { filters, now: NOW }); + const liveTransfers = [ + { ...initial[0], status: 'completed' }, + initial[1], + tx('tx_new', '2026-09-02T10:00:00Z', { status: 'pending' }), + ]; + + const refreshed = resolveTransferPage( + liveTransfers.filter((row) => row.status === 'pending'), + { + filters, + liveTransfers, + cursor: first.page.cursor, + snapshot: first.snapshot, + now: NOW + 1000, + }, + ); + + expect(refreshed.snapshot.id).toBe(first.snapshot.id); + expect(refreshed.page.items.map((row) => row.id)).toEqual(['tx_2', 'tx_1']); + expect(refreshed.page.items[1].status).toBe('completed'); + expect(refreshed.page.totalCount).toBe(2); + }); + + it('rejects a cursor from a same-time, equal-sized replacement snapshot', () => { + const rows = Array.from({ length: 12 }, (_, i) => + tx( + `tx_${String(i).padStart(2, '0')}`, + `2026-09-01T10:${String(i).padStart(2, '0')}:00Z`, + ), + ); + const first = createTransferSnapshot(rows, { now: NOW, pageSize: 5 }); + const firstPage = pageFromSnapshot(first, rows, { now: NOW }); + const changed = [ + tx('tx_new', '2026-09-02T10:00:00Z'), + ...rows.slice(0, 11), + ]; + const replacement = createTransferSnapshot(changed, { + now: NOW, + pageSize: 5, + }); + + // The old page boundary is still present, so it cannot stand in for + // generation identity when a different row changed before that boundary. + expect(first.items[4]).toEqual(replacement.items[4]); + expect(replacement.id).not.toBe(first.id); + expect( + pageFromSnapshot(replacement, changed, { + now: NOW, + cursor: firstPage.nextCursor, + }).code, + ).toBe('cursor_expired'); + + // Even unchanged membership belongs to a new generation when recreated. + expect(createTransferSnapshot(rows, { now: NOW }).id).not.toBe(first.id); + }); + + it('gives same-tick cursor recovery an identity the page can retain', () => { + const rows = [tx('tx_a', '2026-09-01T10:00:00Z')]; + const first = resolveTransferPage(rows, { now: NOW }); + const changed = [tx('tx_b', '2026-09-02T10:00:00Z')]; + const recovered = resolveTransferPage(changed, { + snapshot: first.snapshot, + cursor: 'invalid', + now: NOW, + }); + expect(recovered.recovered).toBe(true); + expect(recovered.snapshot.id).not.toBe(first.snapshot.id); + + // Transfers persists replacements by comparing their IDs. The next + // cursor-free render must not return the superseded row. + const retained = + recovered.snapshot.id !== first.snapshot.id + ? recovered.snapshot + : first.snapshot; + expect( + resolveTransferPage(changed, { + snapshot: retained, + now: NOW, + }).page.items.map((row) => row.id), + ).toEqual(['tx_b']); + }); +}); diff --git a/test/unit/useTransfers.errors.test.jsx b/test/unit/useTransfers.errors.test.jsx index 071426f..11741da 100644 --- a/test/unit/useTransfers.errors.test.jsx +++ b/test/unit/useTransfers.errors.test.jsx @@ -14,7 +14,9 @@ describe('useTransfers error retry policy', () => { const { result } = renderHook(() => useTransfers()); await waitFor(() => { - expect(result.current.error).toBe('Something went wrong. Please try again.'); + expect(result.current.error).toBe( + 'Something went wrong. Please try again.', + ); }); expect(result.current.retryable).toBe(false); diff --git a/vite-plugin-security-headers.js b/vite-plugin-security-headers.js index af430cf..d3fb1d7 100644 --- a/vite-plugin-security-headers.js +++ b/vite-plugin-security-headers.js @@ -66,8 +66,8 @@ export function buildCsp(apiOrig = apiOrigin()) { const directives = [ "default-src 'self'", "script-src 'self'", - "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag - "img-src 'self' data:", // data: URIs used by chart canvas toDataURL + "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag + "img-src 'self' data:", // data: URIs used by chart canvas toDataURL `connect-src ${connectSrc.join(' ')}`, "font-src 'self'", "object-src 'none'", @@ -90,8 +90,7 @@ export const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': - 'camera=(), microphone=(), geolocation=(), payment=()', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',