Compare commits

...

77 Commits

Author SHA1 Message Date
github-actions[bot]
5db5913b86 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
29da17ae76 Cleanup 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
5bf65cb20f Fix typecheck error 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
d487a609ae Coderabbit suggestion 2025-10-27 11:25:26 -07:00
autofix-ci[bot]
8b7d6ba520 [autofix.ci] apply automated fixes 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
153d4e2d18 Update rules test to use pressSequentially 2025-10-27 11:25:26 -07:00
github-actions[bot]
6a77b04ad7 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
fb8f89d411 Fix new payee not being created in tests 2025-10-27 11:25:26 -07:00
github-actions[bot]
874a2cd8cc Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
github-actions[bot]
4dc41356b9 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5795
2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
e7e2fe28b6 Rename to DisplayPayeeProvider 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
3ef0ea256a Cleanup 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
a99846d3f6 Show search if there are 100 payees 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
987aafd4d4 Fix highlight of Create payee 2025-10-27 11:25:26 -07:00
autofix-ci[bot]
e54a81881c [autofix.ci] apply automated fixes 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
7be77b836c Fix payee autocomplete search 2025-10-27 11:25:26 -07:00
Joel Jeremy Marquez
2b87e7c388 Set higher page count 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
22aed82c39 Add DisplayPayeeContextProvider to TransactionsTable.test.tsx 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
5ff59ae3c9 Rename ScrollProvider to useScrollListener and move to hooks folder 2025-10-27 11:25:25 -07:00
Joel Jeremy Marquez
54f4427423 Re-implement useDisplayPayee to use context to minimize SQL queries 2025-10-27 11:25:25 -07:00
Matt Fiddaman
9a3e33c0d7 fix inconsistent widths of bank sync field mapping selects on mobile (#6007) 2025-10-27 11:56:38 +00:00
Matiss Janis Aboltins
25d072944e Refactor account header to use SpaceBetween component for spacing (#5994) 2025-10-24 20:52:59 +01:00
Joel Jeremy Marquez
cf8a4b6e6a Fix InitialFocus not working on some fields (#5987)
* Fix InitialFocus not working on some fields

* Fix typecheck and lint errors

* Fix lint error

* Add ref type

* Add types

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Revert vrt

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Cleanup

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-23 11:34:41 -07:00
David Genord II
55b1ed170b Bump Alpine docker image to 3.22 upgrading from node 18 to 22 (#5989) 2025-10-23 00:42:48 +01:00
Matt Fiddaman
b266ebf1ea 📱 add bank sync settings page to mobile (#5978) 2025-10-22 20:06:44 +01:00
Matiss Janis Aboltins
6826ca0e4b Add virtualizer to mobile transactions (#5921) 2025-10-22 18:07:29 +01:00
Matt Fiddaman
80aee4ee71 ⬆️ bump dependencies before 25.11 (#5983)
* bump dependencies

* note

* recharts differences
2025-10-22 17:28:11 +01:00
dependabot[bot]
ab4aa21343 Bump vite from 7.1.9 to 7.1.11 (#5982)
* Bump vite from 7.1.9 to 7.1.11

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 14:23:36 +01:00
Matt Fiddaman
9dd0284e31 fix report date dropdowns not including current period (#5981) 2025-10-22 13:44:19 +01:00
Matt Fiddaman
dcc879294c fix slow performance in import csv modal (#5980) 2025-10-22 13:43:56 +01:00
lelemm
002f74a8fa Feature: Formula card and Formula for rules (#5939)
* Formula card and Formula rules

* [autofix.ci] apply automated fixes

* File move fix

* [autofix.ci] apply automated fixes

* Update packages/desktop-client/src/components/formula/codeMirror-excelLanguage.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* code rabbit wrong commit suggestion fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-21 21:57:56 -03:00
guiza
b6f80c26e6 fix split transaction sort order when duplicating (#5911)
* Fixes #5715

* Release notes

* [autofix.ci] apply automated fixes

* Fix release notes PR number

* Shorten release notes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 01:32:00 +01:00
qunm
ee71130d56 Fixes #4333 Overlapping text on mobile (#5900)
* made Budgeted header to be multiline and increased font minimum font size

* added release note

* Update upcoming-release-notes/5900.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

---------

Co-authored-by: Quan Nguyen <quannm@Quans-MacBook-Pro.local>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 01:11:20 +01:00
Andrea Colombo
6ed18d8f8c Update link to documentation in docker-compose.yml (#5873)
* Update link to documentation in docker-compose.yml

* Add release notes
2025-10-21 18:35:55 +01:00
dependabot[bot]
1737674b9e Bump vite from 7.1.9 to 7.1.11 (#5972) 2025-10-21 17:24:56 +01:00
Matt Fiddaman
e4617e8cd4 fix GoCardless institutions with special continuous access EUA behaviour (#5967) 2025-10-21 17:24:05 +01:00
Matiss Janis Aboltins
57d01467ca Refactor test execution to use lage task runner (#5964) 2025-10-21 08:58:26 +02:00
Joel Jeremy Marquez
8019d9f61b Update react compiler to v1 (#5971)
* Update react compiler

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5971

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-20 16:14:30 -07:00
Matiss Janis Aboltins
ddbefc790e Fix date validation bug causing crashes with old dates (#5970) 2025-10-20 22:04:32 +01:00
Matiss Janis Aboltins
7eaf23eb7c Improve e2e test stability (#5966) 2025-10-20 19:21:54 +02:00
Michael Clark
8f284e7b60 :electron: New desktop app icons (#5965)
* add new desktop app icons for better display on linux, and to prepare us for mac liquid glass

* png for windows

* release notes

* put old icons in archived folder
2025-10-20 09:24:59 +01:00
lelemm
be35328e42 🐛 Fix to show the notification for updating service worker (#5963)
* Fix to show the notification for updating service worker

* cleanup

* Add release notes for PR #5963

* trigger pipeline

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-19 22:48:05 -03:00
Michael Clark
23f1b7d3c0 :electron: Change desktop app execution name (#5954)
* change entrypoint name

* release notes
2025-10-19 18:30:09 +01:00
Matiss Janis Aboltins
8b1aa6fb93 Adjust account panel borders and corners (#5958) 2025-10-19 15:51:44 +01:00
Matt Fiddaman
155558ee62 drop support for node 20 (#5937)
* node 24

* node types

* dockerfiles

* readme

* note
2025-10-18 23:58:27 +01:00
Stephen Brown II
94332016e8 Adds balance variable to rule templates (#5925)
* Adds balance variable to rule templates

Enables access to the account balance within rule templates. This allows for more complex rule creation based on the current account balance.

Calculates the account balance up to the transaction being processed, including transactions on the same date with lower sort order.

Handles cases where the balance is undefined gracefully, defaulting to 0 to prevent errors.

## AI disclaimer
This PR contains code that was partially or fully generated by AI and may contain errors. All suggestions for improvement are welcome.

* Add release notes

* indexed sql params not supported

* [autofix.ci] apply automated fixes

* Skip parent transactions of splits

* Uses aql for account balance

Updates transaction rule preparation to use aql instead of  sql calculating it from past transactions.

The balance is defaulted to 0 if no account is set.

Refactor account balance calculation to build a proper query with date and sort_order filters

* Add block scoping to switch cases and ensure correct fallthrough handling in Action type conversions

* Corrects transaction rule sorting order

Reverses the sort order comparison in transaction rules
to ensure correct identification of prior transactions with the same date.

This ensures that the correct balance is used when
calculating balance-based rule conditions.

fixup! Corrects transaction rule sorting order

* Improves transaction rule balance calculation

Uses a more efficient query for calculating the account balance
up to a transaction when applying rules, improving performance.
This change reduces the complexity of the balance calculation.

* Apply coderabbit lessons learned

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-18 08:22:55 -07:00
Matiss Janis Aboltins
0af2c6c2fb Update default dashboard (#5924) 2025-10-18 16:34:41 +02:00
Matiss Janis Aboltins
97482a082d fix: prevent sensitive data leakage in error logs (#5948) 2025-10-18 16:33:27 +02:00
Matiss Janis Aboltins
31a9ba629b Change "/update-vrt" workflow (#5952) 2025-10-18 11:46:52 +01:00
Matiss Janis Aboltins
7c19a6333c docs: add AGENTS.md guide for AI agents (#5942) 2025-10-18 00:29:12 +02:00
Matiss Janis Aboltins
2bce9d707c Improve file decryption modal and button labels (#5943) 2025-10-17 23:47:12 +02:00
Michael Clark
4e42cda29e :electron: Prep for flathub - arm64 support and metainfo (#5947)
* prep for flathub -arm64 build and metainfo

* release note

* spelling mistake

* whitespace removal

* arch build issues

* hmm

* removing arm64 from flatpak - that would get bundled by flathub anyway
2025-10-17 21:08:59 +01:00
Matiss Janis Aboltins
0ce07d7692 Align onboarding form element sizes (#5941) 2025-10-16 22:34:51 +01:00
Matiss Janis Aboltins
0e3415c145 Hide refresh button when not connected to sync server (#5940) 2025-10-16 22:31:53 +01:00
Michael Clark
19675a7de6 ⬆️ Update flatpak sdk and platform (#5935)
* update flatpak sdk and platform

* release notes

* flatpak version config

* going to 24.08 to support as many distros as possible

* remove unneded file
2025-10-16 18:59:19 +01:00
Matt Fiddaman
5a1ceed7d9 ⬆️ bump electron (#5936)
* electron 38.3.0

* electron/notarize 3.1.0

* note
2025-10-16 16:22:48 +01:00
andreparames
86c1c30c97 enhancement: fix Cetelem bank transations (#5914)
* enhancement: fix Cetelem bank transations

The amount coming from GoCardless has the opposite sign of what we expect.

* Cetelem bank: make code more future-proof

Change based on code review suggestion

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-16 01:34:27 +01:00
lelemm
96ac1292f9 🐛 Fix Service Worker: New hash every build (#5928)
* New hash every build

* Add release notes for PR #5928

* trigger actions

* Changed from TS to MTS

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-15 13:11:54 -03:00
Matiss Janis Aboltins
328dfae8bf Update DeleteFileModal to disable delete button when server is offline (#5926) 2025-10-14 23:58:12 +02:00
Michael Süssemilch
37247395e2 feat(currency): add currency display to schedules (#5907)
* feat(currency): add currency display to schedules

* refactor: useFormat directly in cell
2025-10-14 20:10:57 +01:00
Daniel Boles
02b0e24d6e budgetfilesSlice: Fix paste-o in fallback sorting by ID (#5918)
* budgetfilesSlice: Fix paste-o in fallback sorting by ID

* Create 5918.md

* 5918.md: Clarify

* 5918.md: Ah, those square brackets don't just indicate a placeholder
2025-10-13 18:57:53 +01:00
Matiss Janis Aboltins
dfaa75f1cf Create e2e tests for mobile payees page (#5905) 2025-10-13 19:42:40 +02:00
Matiss Janis Aboltins
adae3e4352 Virtualize mobile payees list (#5904) 2025-10-13 19:04:32 +02:00
Matiss Janis Aboltins
7a3794295f Enhance mobile rules with undo notifications for save and delete actions (#5906) 2025-10-13 19:02:46 +02:00
Julian Dominguez-Schatz
c1d97fcc75 Allow size-compare action to run immediately after artifact upload (#5912)
* Allow size-compare action to run immediately after artifact upload

* Add release notes
2025-10-13 09:20:18 -07:00
Matiss Janis Aboltins
3715f16888 Add virtualizer to mobile rules list (#5899) 2025-10-11 12:57:39 +02:00
lelemm
edad7ce0e3 New Rule delete transaction action (#4603)
* Delete transaction rule action

* md

* removed debugger

* fix rule execution on csv preview

* Update 4603.md

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-10 21:41:43 -03:00
Matt Fiddaman
737341ffb6 fix hotkey scope warning (#5901) 2025-10-10 19:59:20 +01:00
Matt Fiddaman
6147495003 ⬆️ upgrade recharts to v3 (#5903) 2025-10-10 19:58:59 +01:00
Matiss Janis Aboltins
614bedcfbf Trigger edge docker release on master push (#5897) 2025-10-10 09:37:54 +02:00
Matt Fiddaman
248b1034d7 fix api failing to import (#5896) 2025-10-09 22:55:48 +01:00
Claudio Spizzi
4f88afa266 Fix the Authentik external help URL for OIDC (#5891) 2025-10-08 21:41:16 -03:00
Matt Fiddaman
5c23aad3c2 ⬆️ bump yarn to 4.10.x (#5885)
* bump yarn to 4.10.3

* note

* fix flaky test
2025-10-08 23:21:37 +01:00
Michael Clark
5b9bcc94f6 ⬆️ Upgrade electron-builder (#5857)
* upgrade electron-builder

* no postinstall when yarn immutable

* downgrade to last version without the bug

* fix issue with wrappy

* release notes

* regen yarn lock

* react-sprint

* hmm

* update yarn manually...

* fixing spring?

* lock

* finally

* conflict

* put package back
2025-10-08 22:20:29 +01:00
Matt Fiddaman
87d54251cd ⬆️ bump loot-core dependencies (#5888)
* csv-stringify 6.6.0

* lru-cache 11.2.2

* ua-parser-js 2.0.5

* @reduxjs/toolkit 2.9.0

* jest-diff 30.2.0

* csv-parse 6.1.0

* yargs 18.0.0

* slash 5.1.0

* drop memfs

* peggy 5.0.6

* fast-check 4.3.0

* fake-indexeddb 6.2.2

* note
2025-10-08 21:16:34 +01:00
Matt Fiddaman
2e439aacba ⬆️ bump various dependencies (#5886)
* bump various dependencies

* note

* update playwright images

* globals 16.4.0

* react-router 7.9.4
2025-10-08 21:01:27 +01:00
gdufay
244140314c Enhancements: Use remark plugin in the report's Text widget (#5850)
* Add remark plugin to MarkdownCard

* Generate release note

* [autofix.ci] apply automated fixes

* Avoid duplicate css code

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-08 17:26:31 +01:00
378 changed files with 14724 additions and 7500 deletions

View File

@@ -1,12 +0,0 @@
---
alwaysApply: true
---
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
The following commands can be useful:
- `yarn typecheck` to run typechecker
- `yarn lint` to run the code linter and formatter
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
- `yarn test` to run all the tests

View File

@@ -1,37 +0,0 @@
---
description:
globs: *.ts,*.tsx
alwaysApply: false
---
You are an expert in TypeScript and React.
Code Style and Structure
- Write concise, technical TypeScript code.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
Naming Conventions
- Favor named exports for components and utilities.
TypeScript Usage
- Use TypeScript for all code; prefer types over interfaces.
- Avoid enums; use objects or maps instead.
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX, keeping JSX minimal and readable.
Change validation
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct

View File

@@ -1,14 +0,0 @@
---
description:
globs:
alwaysApply: true
---
Vitest test runner is used for unit tests.
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
To run unit tests for a specific package in the monorepo, use the following command:
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.

3
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,3 @@
{
"setup-worktree": ["yarn"]
}

View File

@@ -17,7 +17,7 @@ runs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Install yarn
run: npm install -g yarn
shell: bash
@@ -32,6 +32,16 @@ runs:
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Ensure Lage cache directory exists
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@v4
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
restore-keys: |
lage-${{ runner.os }}-
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable

View File

@@ -24,6 +24,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build API
run: cd packages/api && yarn build
- name: Create package tgz
@@ -40,6 +42,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CRDT
run: cd packages/crdt && yarn build
- name: Create package tgz
@@ -75,6 +79,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build

View File

@@ -17,6 +17,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Lint
run: yarn lint
typecheck:
@@ -25,6 +27,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Typecheck
run: yarn typecheck
validate-cli:
@@ -33,6 +37,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build Web
run: yarn build:server
- name: Check that the built CLI works
@@ -43,6 +49,8 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Test
run: yarn test
@@ -53,6 +61,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Check migrations
run: node ./.github/actions/check-migrations.js

View File

@@ -1,21 +1,16 @@
name: Build Edge Docker Image
# Edge Docker images are built for every commit, and daily
# Edge Docker images are built for every push to master
on:
push:
branches:
- master
paths:
- 'packages/sync-server/**'
pull_request:
branches:
- master
paths:
- 'packages/sync-server/**'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
concurrency:
group: docker-edge-build
cancel-in-progress: true
permissions:
contents: read
packages: write

View File

@@ -32,11 +32,13 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run E2E Tests on Netlify URL
run: yarn e2e
env:
@@ -53,11 +55,13 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
@@ -74,7 +78,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment

View File

@@ -44,9 +44,9 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac
@@ -57,6 +57,7 @@ jobs:
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron

View File

@@ -39,9 +39,9 @@ jobs:
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Platform/x86_64/23.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron

View File

@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Handle feature requests
run: node .github/actions/handle-feature-requests.js
env:

View File

@@ -73,7 +73,7 @@ jobs:
- name: Setup node and npm registry
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -56,7 +56,7 @@ jobs:
- name: Setup node and npm registry
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Web

View File

@@ -54,6 +54,7 @@ jobs:
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
@@ -62,6 +63,7 @@ jobs:
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: head
allow_forks: true

View File

@@ -1,119 +0,0 @@
name: /update-vrt
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
cancel-in-progress: true
jobs:
update-vrt:
name: Update VRT
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.55.1-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch
run: |
git config --system --add safe.directory "*"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git reset
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git format-patch -1 HEAD --stdout > Update-VRT.patch
- uses: actions/upload-artifact@v4
with:
name: patch
path: Update-VRT.patch
push-patch:
runs-on: ubuntu-latest
needs: update-vrt
permissions:
contents: write
pull-requests: write
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: actions/download-artifact@v4
continue-on-error: true
with:
name: patch
- name: Apply patch and push
env:
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git apply Update-VRT.patch
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git push origin HEAD:${BRANCH_NAME}
- name: Add finished reaction
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: 'rocket'
add-starting-reaction:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: React to comment
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: '+1'

156
.github/workflows/vrt-update-apply.yml vendored Normal file
View File

@@ -0,0 +1,156 @@
name: VRT Update - Apply
# SECURITY: This workflow runs in trusted base repo context.
# It treats the patch artifact as untrusted data, validates it contains only PNGs,
# and safely applies it to the contributor's fork branch.
on:
workflow_run:
workflows: ['VRT Update - Generate']
types:
- completed
permissions:
contents: write
pull-requests: write
jobs:
apply-vrt-updates:
name: Apply VRT Updates
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-patch-*
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: vrt-metadata-*
path: /tmp/metadata
- name: Extract metadata
id: metadata
run: |
# Find the metadata directory (will be vrt-metadata-{PR_NUMBER})
METADATA_DIR=$(find /tmp/metadata -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [ -z "$METADATA_DIR" ]; then
echo "No metadata found, skipping..."
exit 0
fi
PR_NUMBER=$(cat "$METADATA_DIR/pr-number.txt")
HEAD_REF=$(cat "$METADATA_DIR/head-ref.txt")
HEAD_REPO=$(cat "$METADATA_DIR/head-repo.txt")
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
echo "Found PR #$PR_NUMBER: $HEAD_REPO @ $HEAD_REF"
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@v4
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Validate and apply patch
if: steps.metadata.outputs.pr_number != ''
id: apply
run: |
# Find the patch file
PATCH_DIR=$(find /tmp/artifacts -mindepth 1 -maxdepth 1 -type d | head -n 1)
PATCH_FILE="$PATCH_DIR/vrt-update.patch"
if [ ! -f "$PATCH_FILE" ]; then
echo "No patch file found"
exit 0
fi
echo "Found patch file: $PATCH_FILE"
# Validate patch only contains PNG files
echo "Validating patch contains only PNG files..."
if grep -E '^(\+\+\+|---) [ab]/' "$PATCH_FILE" | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files! Rejecting for security."
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch validation failed: contains non-PNG files" >> "$GITHUB_OUTPUT"
exit 1
fi
# Extract file list for verification
FILES_CHANGED=$(grep -E '^\+\+\+ b/' "$PATCH_FILE" | sed 's/^+++ b\///' | wc -l)
echo "Patch modifies $FILES_CHANGED PNG file(s)"
# Configure git
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Apply patch
echo "Applying patch..."
if git apply --check "$PATCH_FILE" 2>&1; then
git apply "$PATCH_FILE"
# Stage only PNG files (extra safety)
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes after applying patch"
echo "applied=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Commit
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${{ steps.metadata.outputs.pr_number }}"
echo "applied=true" >> "$GITHUB_OUTPUT"
else
echo "Patch could not be applied cleanly"
echo "applied=false" >> "$GITHUB_OUTPUT"
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
exit 1
fi
- name: Push changes
if: steps.apply.outputs.applied == 'true'
env:
HEAD_REF: ${{ steps.metadata.outputs.head_ref }}
HEAD_REPO: ${{ steps.metadata.outputs.head_repo }}
run: |
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "Successfully pushed VRT updates to $HEAD_REPO@$HEAD_REF"
- name: Comment on PR - Success
if: steps.apply.outputs.applied == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ VRT screenshots have been automatically updated.'
});
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@v7
with:
script: |
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';
await github.rest.issues.createComment({
issue_number: ${{ steps.metadata.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `❌ Failed to apply VRT updates: ${error}\n\nPlease check the workflow logs for details.`
});

View File

@@ -0,0 +1,105 @@
name: VRT Update - Generate
# SECURITY: This workflow runs in untrusted fork context with no write permissions.
# It only generates VRT patch artifacts that are later applied by vrt-update-apply.yml
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/**'
- '.github/workflows/vrt-update-generate.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
generate-vrt-updates:
name: Generate VRT Updates
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
continue-on-error: true
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Stage only PNG files
git add "**/*.png"
# Check if there are any changes
if git diff --staged --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No VRT changes to commit"
exit 0
fi
echo "has_changes=true" >> "$GITHUB_OUTPUT"
# Create commit and patch
git commit -m "Update VRT screenshots"
git format-patch -1 HEAD --stdout > vrt-update.patch
# Validate patch only contains PNG files
if grep -E '^(\+\+\+|---) [ab]/' vrt-update.patch | grep -v '\.png$'; then
echo "ERROR: Patch contains non-PNG files!"
exit 1
fi
echo "Patch created successfully with PNG changes only"
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
with:
name: vrt-patch-${{ github.event.pull_request.number }}
path: vrt-update.patch
retention-days: 5
- name: Save PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
run: |
mkdir -p pr-metadata
echo "${{ github.event.pull_request.number }}" > pr-metadata/pr-number.txt
echo "${{ github.event.pull_request.head.ref }}" > pr-metadata/head-ref.txt
echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-metadata/head-repo.txt
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@v4
with:
name: vrt-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/
retention-days: 5

6
.gitignore vendored
View File

@@ -7,9 +7,6 @@ Actual-*
**/xcuserdata/*
export-2020-01-10.csv
# Secrets
.secret-tokens
# MacOS
.DS_Store
@@ -65,3 +62,6 @@ build/
# .d.ts files aren't type-checked with skipLibCheck set to true
*.d.ts
# Lage cache
.lage/

2
.nvmrc
View File

@@ -1 +1 @@
v20/*
v22/*

View File

@@ -1,2 +0,0 @@
export APPLE_ID=example@email.com
export APPLE_APP_SPECIFIC_PASSWORD=password

942
.yarn/releases/yarn-4.10.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
yarnPath: .yarn/releases/yarn-4.10.3.cjs

585
AGENTS.md Normal file
View File

@@ -0,0 +1,585 @@
# AGENTS.md - Guide for AI Agents Working with Actual Budget
This guide provides comprehensive information for AI agents (like Cursor) working with the Actual Budget codebase.
## Project Overview
**Actual Budget** is a local-first personal finance tool written in TypeScript/JavaScript. It's 100% free and open-source with synchronization capabilities across devices.
- **Repository**: https://github.com/actualbudget/actual
- **Community Docs**: https://github.com/actualbudget/docs or https://actualbudget.org/docs
- **License**: MIT
- **Primary Language**: TypeScript (with React)
- **Build System**: Yarn 4 workspaces (monorepo)
## Quick Start Commands
### Essential Commands (Run from Root)
```bash
# Type checking (ALWAYS run before committing)
yarn typecheck
# Linting and formatting (with auto-fix)
yarn lint:fix
# Run all tests
yarn test
# Start development server (browser)
yarn start
# Start with sync server
yarn start:server-dev
# Start desktop app development
yarn start:desktop
```
### Important Rules
- **ALWAYS run yarn commands from the root directory** - never run them in child workspaces
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
- Tests run once and exit by default (using `vitest --run`)
### Task Orchestration with Lage
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
- **Parallel execution**: Runs tests in parallel across workspaces for faster feedback
- **Smart caching**: Caches test results to skip unchanged packages (cached in `.lage/` directory)
- **Dependency awareness**: Understands workspace dependencies and execution order
- **Continues on error**: Uses `--continue` flag to run all packages even if one fails
**Lage Commands:**
```bash
# Run all tests across all packages
yarn test # Equivalent to: lage test --continue
# Run tests without cache (for debugging/CI)
yarn test:debug # Equivalent to: lage test --no-cache --continue
```
Configuration is in `lage.config.js` at the project root.
## Architecture & Package Structure
### Core Packages
#### 1. **loot-core** (`packages/loot-core/`)
The core application logic that runs on any platform.
- Business logic, database operations, and calculations
- Platform-agnostic code
- Exports for both browser and node environments
- Test commands:
```bash
# Run all loot-core tests
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
```
#### 2. **desktop-client** (`packages/desktop-client/` - aliased as `@actual-app/web`)
The React-based UI for web and desktop.
- React components using functional programming patterns
- E2E tests using Playwright
- Vite for bundling
- Commands:
```bash
# Development
yarn workspace @actual-app/web start:browser
# Build
yarn workspace @actual-app/web build
# E2E tests
yarn workspace @actual-app/web e2e
# Visual regression tests
yarn workspace @actual-app/web vrt
```
#### 3. **desktop-electron** (`packages/desktop-electron/`)
Electron wrapper for the desktop application.
- Window management and native OS integration
- E2E tests for Electron-specific features
#### 4. **api** (`packages/api/` - aliased as `@actual-app/api`)
Public API for programmatic access to Actual.
- Node.js API
- Designed for integrations and automation
- Commands:
```bash
# Build
yarn workspace @actual-app/api build
# Run tests
yarn workspace @actual-app/api test
# Or use lage to run all tests
yarn test
```
#### 5. **sync-server** (`packages/sync-server/` - aliased as `@actual-app/sync-server`)
Synchronization server for multi-device support.
- Express-based server
- Currently transitioning to TypeScript (mostly JavaScript)
- Commands:
```bash
yarn workspace @actual-app/sync-server start
```
#### 6. **component-library** (`packages/component-library/` - aliased as `@actual-app/components`)
Reusable React UI components.
- Shared components like Button, Input, Menu, etc.
- Theme system and design tokens
- Icons (375+ icons in SVG/TSX format)
#### 7. **crdt** (`packages/crdt/` - aliased as `@actual-app/crdt`)
CRDT (Conflict-free Replicated Data Type) implementation for data synchronization.
- Protocol buffers for serialization
- Core sync logic
#### 8. **plugins-service** (`packages/plugins-service/`)
Service for handling plugins/extensions.
#### 9. **eslint-plugin-actual** (`packages/eslint-plugin-actual/`)
Custom ESLint rules specific to Actual.
- `no-untranslated-strings`: Enforces i18n usage
- `prefer-trans-over-t`: Prefers Trans component over t() function
- `prefer-logger-over-console`: Enforces using logger instead of console
- `typography`: Typography rules
- `prefer-if-statement`: Prefers explicit if statements
## Development Workflow
### 1. Making Changes
When implementing changes:
1. Read relevant files to understand current implementation
2. Make focused, incremental changes
3. Run type checking: `yarn typecheck`
4. Run linting: `yarn lint:fix`
5. Run relevant tests
6. Fix any linter errors that are introduced
### 2. Testing Strategy
**Unit Tests (Vitest)**
The project uses **lage** for running tests across all workspaces efficiently.
```bash
# Run all tests across all packages (using lage)
yarn test
# Run tests without cache (for debugging)
yarn test:debug
# Run tests for a specific package
yarn workspace loot-core run test
# Run a specific test file (watch mode)
yarn workspace loot-core run test path/to/test.test.ts
```
**E2E Tests (Playwright)**
```bash
# Run E2E tests for web
yarn e2e
# Desktop Electron E2E (includes full build)
yarn e2e:desktop
# Visual regression tests
yarn vrt
# Visual regression in Docker (consistent environment)
yarn vrt:docker
# Run E2E tests for a specific package
yarn workspace @actual-app/web e2e
```
**Testing Best Practices:**
- Minimize mocked dependencies - prefer real implementations
- Use descriptive test names
- Vitest globals are available: `describe`, `it`, `expect`, `beforeEach`, etc.
- For sync-server tests, globals are explicitly defined in config
### 3. Type Checking
TypeScript configuration uses:
- Incremental compilation
- Strict type checking with `typescript-strict-plugin`
- Platform-specific exports in `loot-core` (node vs browser)
Always run `yarn typecheck` before committing.
### 4. Internationalization (i18n)
- Use `Trans` component instead of `t()` function when possible
- All user-facing strings must be translated
- Generate i18n files: `yarn generate:i18n`
- Custom ESLint rules enforce translation usage
## Code Style & Conventions
### TypeScript Guidelines
**Type Usage:**
- Use TypeScript for all code
- Prefer `type` over `interface`
- Avoid `enum` - use objects or maps
- Avoid `any` or `unknown` unless absolutely necessary
- Look for existing type definitions in the codebase
- Avoid type assertions (`as`, `!`) - prefer `satisfies`
- Use inline type imports: `import { type MyType } from '...'`
**Naming:**
- Use descriptive variable names with auxiliary verbs (e.g., `isLoaded`, `hasError`)
- Named exports for components and utilities (avoid default exports except in specific cases)
**Code Structure:**
- Functional and declarative programming patterns - avoid classes
- Use the `function` keyword for pure functions
- Prefer iteration and modularization over code duplication
- Structure files: exported component/page, helpers, static content, types
- Create new components in their own files
**React Patterns:**
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
- Use custom hooks from `src/hooks` (not react-router directly):
- `useNavigate()` from `src/hooks` (not react-router)
- `useDispatch()`, `useSelector()`, `useStore()` from `src/redux` (not react-redux)
- Avoid unstable nested components
- Use `satisfies` for type narrowing
**JSX Style:**
- Declarative JSX, minimal and readable
- Avoid unnecessary curly braces in conditionals
- Use concise syntax for simple statements
- Prefer explicit expressions (`condition && <Component />`)
### Import Organization
Imports are automatically organized by ESLint with the following order:
1. React imports (first)
2. Built-in Node.js modules
3. External packages
4. Actual packages (`loot-core`, `@actual-app/components` - legacy pattern `loot-design` may appear in old code)
5. Parent imports
6. Sibling imports
7. Index imports
Always maintain newlines between import groups.
### Platform-Specific Code
- Don't directly reference platform-specific imports (`.api`, `.web`, `.electron`)
- Use conditional exports in `loot-core` for platform-specific code
- Platform resolution happens at build time via package.json exports
### Restricted Patterns
**Never:**
- Use `console.*` (use logger instead - enforced by ESLint)
- Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'`
- Import colors directly - use theme instead
- Import `@actual-app/web/*` in `loot-core`
**Git Commands:**
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
## File Structure Patterns
### Typical Component File
```typescript
import { type ComponentType } from 'react';
// ... other imports
type MyComponentProps = {
// Props definition
};
export function MyComponent({ prop1, prop2 }: MyComponentProps) {
// Component logic
return (
// JSX
);
}
```
### Test File
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
// ... imports
describe('ComponentName', () => {
it('should behave as expected', () => {
// Test logic
expect(result).toBe(expected);
});
});
```
## Important Directories & Files
### Configuration Files
- `/package.json` - Root workspace configuration, scripts
- `/lage.config.js` - Lage task runner configuration
- `/eslint.config.mjs` - ESLint configuration (flat config format)
- `/tsconfig.json` - Root TypeScript configuration
- `/.cursorignore`, `/.gitignore` - Ignored files
- `/yarn.lock` - Dependency lockfile (Yarn 4)
### Documentation
- `/README.md` - Project overview
- `/CONTRIBUTING.md` - Points to community docs
- `/upcoming-release-notes/` - Release notes for next version
- `/CODEOWNERS` - Code ownership definitions
### Build Artifacts (Don't Edit)
- `packages/*/lib-dist/` - Built output
- `packages/*/dist/` - Built output
- `packages/*/build/` - Built output
- `packages/desktop-client/playwright-report/` - Test reports
- `packages/desktop-client/test-results/` - Test results
- `.lage/` - Lage task runner cache (improves test performance)
### Key Source Directories
- `packages/loot-core/src/client/` - Client-side core logic
- `packages/loot-core/src/server/` - Server-side core logic
- `packages/loot-core/src/shared/` - Shared utilities
- `packages/loot-core/src/types/` - Type definitions
- `packages/desktop-client/src/components/` - React components
- `packages/desktop-client/src/hooks/` - Custom React hooks
- `packages/desktop-client/e2e/` - End-to-end tests
- `packages/component-library/src/` - Reusable components
- `packages/component-library/src/icons/` - Icon components (auto-generated, don't edit)
## Common Development Tasks
### Running Specific Tests
```bash
# Run all tests across all packages (recommended)
yarn test
# Unit test for a specific file in loot-core (watch mode)
yarn workspace loot-core run test src/path/to/file.test.ts
# E2E test for a specific file
yarn workspace @actual-app/web run playwright test accounts.test.ts --browser=chromium
```
### Building for Production
```bash
# Browser build
yarn build:browser
# Desktop build
yarn build:desktop
# API build
yarn build:api
# Sync server build
yarn build:server
```
### Type Checking Specific Packages
TypeScript uses project references. Run `yarn typecheck` from root to check all packages.
### Debugging Tests
```bash
# Run tests in debug mode (without parallelization)
yarn test:debug
# Run specific E2E test with headed browser
yarn workspace @actual-app/web run playwright test --headed --debug accounts.test.ts
```
### Working with Icons
Icons in `packages/component-library/src/icons/` are auto-generated. Don't manually edit them.
## Troubleshooting
### Type Errors
1. Run `yarn typecheck` to see all type errors
2. Check if types are imported correctly
3. Look for existing type definitions in `packages/loot-core/src/types/`
4. Use `satisfies` instead of `as` for type narrowing
### Linter Errors
1. Run `yarn lint:fix` to auto-fix many issues
2. Check ESLint output for specific rule violations
3. Custom rules:
- `actual/no-untranslated-strings` - Add i18n
- `actual/prefer-trans-over-t` - Use Trans component
- `actual/prefer-logger-over-console` - Use logger
- Check `eslint.config.mjs` for complete rules
### Test Failures
1. Check if test is running in correct environment (node vs web)
2. For Vitest: check `vitest.config.ts` or `vitest.web.config.ts`
3. For Playwright: check `playwright.config.ts`
4. Ensure mock minimization - prefer real implementations
5. **Lage cache issues**: Clear cache with `rm -rf .lage` if tests behave unexpectedly
6. **Tests continue on error**: With `--continue` flag, all packages run even if one fails
### Import Resolution Issues
1. Check `tsconfig.json` for path mappings
2. Check package.json `exports` field (especially for loot-core)
3. Verify platform-specific imports (`.web`, `.electron`, `.api`)
4. Use absolute imports in `desktop-client` (enforced by ESLint)
### Build Failures
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
2. Reinstall dependencies: `yarn install`
3. Check Node.js version (requires >=20)
4. Check Yarn version (requires ^4.9.1)
## Testing Patterns
### Unit Tests
- Located alongside source files or in `__tests__` directories
- Use `.test.ts`, `.test.tsx`, `.spec.js` extensions
- Vitest is the test runner
- Minimize mocking - prefer real implementations
### E2E Tests
- Located in `packages/desktop-client/e2e/`
- Use Playwright test runner
- Visual regression snapshots in `*-snapshots/` directories
- Page models in `e2e/page-models/` for reusable page interactions
- Mobile tests have `.mobile.test.ts` suffix
### Visual Regression Tests (VRT)
- Snapshots stored per test file in `*-snapshots/` directories
- Use Docker for consistent environment: `yarn vrt:docker`
## Additional Resources
- **Community Documentation**: https://actualbudget.org/docs/contributing/
- **Discord Community**: https://discord.gg/pRYNYr4W5A
- **GitHub Issues**: https://github.com/actualbudget/actual/issues
- **Feature Requests**: Label "needs votes" sorted by reactions
## Code Quality Checklist
Before committing changes, ensure:
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] No new console.\* usage (use logger)
- [ ] User-facing strings are translated
- [ ] Prefer `type` over `interface`
- [ ] Named exports used (not default exports)
- [ ] Imports are properly ordered
- [ ] Platform-specific code uses proper exports
- [ ] No unnecessary type assertions
## Pull Request Guidelines
When creating pull requests:
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
## Performance Considerations
- **Bundle Size**: Check with rollup-plugin-visualizer
- **Type Checking**: Uses incremental compilation
- **Testing**: Tests run in parallel by default
- **Linting**: ESLint caches results for faster subsequent runs
## Workspace Commands Reference
```bash
# List all workspaces
yarn workspaces list
# Run command in specific workspace
yarn workspace <workspace-name> run <command>
# Run command in all workspaces
yarn workspaces foreach --all run <command>
# Install production dependencies only (for server deployment)
yarn install:server
```
## Environment Requirements
- **Node.js**: >=20
- **Yarn**: ^4.9.1 (managed by packageManager field)
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
## Migration Notes
The codebase is actively being migrated:
- **JavaScript → TypeScript**: sync-server is in progress
- **Classes → Functions**: Prefer functional patterns
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
When working with older code, follow the newer patterns described in this guide.

View File

@@ -5,7 +5,7 @@
# you are doing.
###################################################
FROM node:20-bullseye as dev
FROM node:22-bookworm as dev
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
WORKDIR /app
CMD ["sh", "./bin/docker-start"]

View File

@@ -62,14 +62,11 @@ yarn workspace desktop-electron update-client
echo "Skipping exe build"
else
if [ "$RELEASE" == "production" ]; then
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build
echo "Created release"
else
SKIP_NOTARIZATION=true yarn build
yarn build
fi
fi
)

View File

@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
echo "E2E_START_URL: $E2E_START_URL"
echo "VRT_ARGS: $VRT_ARGS"
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -1,5 +1,6 @@
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
@@ -71,7 +72,7 @@ const confusingBrowserGlobals = [
'top',
];
export default pluginTypescript.config(
export default defineConfig(
{
ignores: [
'packages/api/app/bundle.api.js',

30
lage.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
test: {
type: 'npmScript',
options: {
outputGlob: [
'coverage/**',
'**/test-results/**',
'**/playwright-report/**',
],
},
},
build: {
type: 'npmScript',
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
},
cacheOptions: {
cacheStorageConfig: {
provider: 'local',
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
},
},
npmClient: 'yarn',
concurrency: 2,
};

View File

@@ -40,12 +40,12 @@
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
"test": "lage test --continue",
"test:debug": "lage test --no-cache --continue",
"e2e": "yarn workspace @actual-app/web run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
@@ -58,9 +58,9 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.18.8",
"@types/node": "^22.18.11",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.45.0",
"@typescript-eslint/parser": "^8.46.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
@@ -68,22 +68,23 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"globals": "^16.4.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.14",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.0.1",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"p-limit": "^7.1.1",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.45.0",
"typescript-eslint": "^8.46.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
@@ -91,7 +92,7 @@
"socks": ">=2.8.3"
},
"engines": {
"node": ">=20",
"node": ">=22",
"yarn": "^4.9.1"
},
"lint-staged": {
@@ -100,7 +101,7 @@
"prettier --write"
]
},
"packageManager": "yarn@4.9.1",
"packageManager": "yarn@4.10.3",
"browserslist": [
"electron >= 35.0",
"defaults"

View File

@@ -19,7 +19,7 @@
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run build:app && yarn run build:crdt && vitest",
"test": "yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types"
},
"dependencies": {

View File

@@ -1 +1,6 @@
export { amountToInteger, integerToAmount } from 'loot-core/shared/util';
// @ts-ignore: bundle not available until we build it
// eslint-disable-next-line import/extensions
import * as bundle from './app/bundle.api.js';
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;

View File

@@ -5,5 +5,11 @@ export default {
// print only console.error
return type === 'stderr';
},
poolOptions: {
threads: {
maxThreads: 2,
minThreads: 1,
},
},
},
};

View File

@@ -6,6 +6,6 @@
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest"
"test": "vitest --run"
}
}

View File

@@ -5,5 +5,10 @@ export default defineConfig({
globals: true,
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
environment: 'node',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@@ -13,7 +13,7 @@
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.0",
"@types/react": "^19.2.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"vitest": "^3.2.4"
@@ -54,6 +54,6 @@
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
"test": "npm-run-all -cp 'test:*'",
"test:web": "ENV=web vitest -c vitest.web.config.ts"
"test:web": "ENV=web vitest --run -c vitest.web.config.ts"
}
}

View File

@@ -4,6 +4,7 @@ import {
isValidElement,
type ReactElement,
Ref,
RefObject,
useEffect,
useRef,
} from 'react';
@@ -11,15 +12,20 @@ import {
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
children:
| ReactElement<{ ref: Ref<T> }>
| ((ref: RefObject<T | null>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,

View File

@@ -34,6 +34,7 @@ export const Popover = ({
return (
<ReactAriaPopover
data-popover={true}
ref={ref}
placement="bottom end"
offset={1}

View File

@@ -21,6 +21,12 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
include: ['src/**/*.web.test.(js|jsx|ts|tsx)'],
poolOptions: {
threads: {
maxThreads: 2,
minThreads: 1,
},
},
},
resolve: {
alias: [

View File

@@ -12,7 +12,7 @@
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --globals"
"test": "vitest --run --globals"
},
"dependencies": {
"google-protobuf": "^3.21.4",

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.55.1-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

View File

@@ -9,7 +9,6 @@ rm -fr build
export IS_GENERIC_BROWSER=1
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH=`ls "$ROOT"/../service-worker/plugin-sw.*.js | sed 's/.*plugin-sw\.\(.*\)\.js/\1/'`
yarn build

View File

@@ -6,6 +6,5 @@ cd "$ROOT/.."
export IS_GENERIC_BROWSER=1
export PORT=3001
export REACT_APP_BACKEND_WORKER_HASH="dev"
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH="dev"
yarn start

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -123,11 +123,14 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
if (screenshot) await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
if (screenshot) await expect(page).toMatchThemeScreenshots();
await importButton.click();
await expect(importButton).not.toBeVisible();
@@ -146,12 +149,14 @@ test.describe('Accounts', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
await expect(page).toMatchThemeScreenshots();
const importButton = accountPage.page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.waitFor({ state: 'visible' });
await expect(page).toMatchThemeScreenshots();
await expect(importButton).toBeDisabled();
await expect(await importButton.innerText()).toMatch(
/Import 0 transactions/,

View File

@@ -0,0 +1,64 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test.describe('Mobile Bank Sync', () => {
let page: Page;
let navigation: MobileNavigation;
let bankSyncPage: MobileBankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
bankSyncPage = await navigation.goToBankSyncPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(
page.getByRole('heading', { name: 'Bank Sync' }),
).toBeVisible();
await expect(bankSyncPage.searchBox).toBeVisible();
await expect(bankSyncPage.searchBox).toHaveAttribute(
'placeholder',
'Filter accounts…',
);
await expect(page).toMatchThemeScreenshots();
});
test('searches for accounts', async () => {
await bankSyncPage.searchFor('Checking');
await expect(bankSyncPage.searchBox).toHaveValue('Checking');
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
await bankSyncPage.searchFor('NonExistentAccount123456789');
const emptyMessage = page.getByText(/No accounts found/);
await expect(emptyMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
});

View File

@@ -0,0 +1,35 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { type BankSyncPage } from './page-models/bank-sync-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
test.describe('Bank Sync', () => {
let page: Page;
let navigation: Navigation;
let bankSyncPage: BankSyncPage;
let configurationPage: ConfigurationPage;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
navigation = new Navigation(page);
configurationPage = new ConfigurationPage(page);
await page.goto('/');
await configurationPage.createTestFile();
});
test.afterAll(async () => {
await page.close();
});
test.beforeEach(async () => {
bankSyncPage = await navigation.goToBankSyncPage();
});
test('checks the page visuals', async () => {
await bankSyncPage.waitToLoad();
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -194,47 +194,71 @@ export class AccountPage {
transaction: TransactionEntry,
) {
if (transaction.debit) {
// double click to ensure the content is selected when adding split transactions
await transactionRow.getByTestId('debit').dblclick();
await this.page.keyboard.type(transaction.debit);
const debitCell = transactionRow.getByTestId('debit');
await debitCell.click();
const debitInput = debitCell.getByRole('textbox');
await this.selectInputText(debitInput);
await debitInput.pressSequentially(transaction.debit);
await this.page.keyboard.press('Tab');
}
if (transaction.credit) {
await transactionRow.getByTestId('credit').click();
await this.page.keyboard.type(transaction.credit);
const creditCell = transactionRow.getByTestId('credit');
await creditCell.click();
const creditInput = creditCell.getByRole('textbox');
await this.selectInputText(creditInput);
await creditInput.pressSequentially(transaction.credit);
await this.page.keyboard.press('Tab');
}
if (transaction.account) {
await transactionRow.getByTestId('account').click();
await this.page.keyboard.type(transaction.account);
const accountCell = transactionRow.getByTestId('account');
await accountCell.click();
const accountInput = accountCell.getByRole('textbox');
await this.selectInputText(accountInput);
await accountInput.pressSequentially(transaction.account);
await this.page.keyboard.press('Tab');
}
if (transaction.payee) {
await transactionRow.getByTestId('payee').click();
await this.page.keyboard.type(transaction.payee);
const payeeCell = transactionRow.getByTestId('payee');
await payeeCell.click();
const payeeInput = payeeCell.getByRole('textbox');
await this.selectInputText(payeeInput);
await payeeInput.pressSequentially(transaction.payee);
await this.page.keyboard.press('Tab');
}
if (transaction.notes) {
await transactionRow.getByTestId('notes').click();
await this.page.keyboard.type(transaction.notes);
const notesCell = transactionRow.getByTestId('notes');
await notesCell.click();
const notesInput = notesCell.getByRole('textbox');
await this.selectInputText(notesInput);
await notesInput.pressSequentially(transaction.notes);
await this.page.keyboard.press('Tab');
}
if (transaction.category) {
await transactionRow.getByTestId('category').click();
const categoryCell = transactionRow.getByTestId('category');
await categoryCell.click();
if (transaction.category === 'split') {
await this.page.getByTestId('split-transaction-button').click();
} else {
await this.page.keyboard.type(transaction.category);
const categoryInput = categoryCell.getByRole('textbox');
await this.selectInputText(categoryInput);
await categoryInput.pressSequentially(transaction.category);
await this.page.keyboard.press('Tab');
}
}
}
async selectInputText(input: Locator) {
const value = await input.inputValue();
if (value) {
await input.selectText();
}
}
}
class FilterTooltip {

View File

@@ -0,0 +1,13 @@
import { type Page } from '@playwright/test';
export class BankSyncPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
}

View File

@@ -29,10 +29,14 @@ export class CustomReportPage {
async selectMode(mode: 'total' | 'time') {
switch (mode) {
case 'total':
await this.pageContent.getByRole('button', { name: 'Total' }).click();
await this.pageContent
.getByRole('button', { name: 'Total', exact: true })
.click();
break;
case 'time':
await this.pageContent.getByRole('button', { name: 'Time' }).click();
await this.pageContent
.getByRole('button', { name: 'Time', exact: true })
.click();
break;
default:
throw new Error(`Unrecognized mode: ${mode}`);

View File

@@ -0,0 +1,171 @@
import { type Page, type Locator } from '@playwright/test';
type ConditionsEntry = {
field: string;
op: string;
value: string;
};
type ActionsEntry = {
field: string;
op?: string;
value: string;
};
type SplitsEntry = {
field: string;
op?: string;
value?: string;
};
type RuleEntry = {
conditionsOp?: string | RegExp;
conditions?: ConditionsEntry[];
actions?: ActionsEntry[];
splits?: Array<SplitsEntry[]>;
};
export class EditRuleModal {
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly conditionsOpButton: Locator;
readonly conditionList: Locator;
readonly actionList: Locator;
readonly splitIntoMultipleTransactionsButton: Locator;
readonly saveButton: Locator;
readonly cancelButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.conditionsOpButton = locator
.getByTestId('conditions-op')
.getByRole('button');
this.conditionList = locator.getByTestId('condition-list');
this.actionList = locator.getByTestId('action-list');
this.splitIntoMultipleTransactionsButton = locator.getByTestId(
'add-split-transactions',
);
this.saveButton = locator.getByRole('button', { name: 'Save' });
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
}
async fill(data: RuleEntry) {
if (data.conditionsOp) {
await this.selectConditionsOp(data.conditionsOp);
}
if (data.conditions) {
await this.fillEditorFields(data.conditions, this.conditionList, true);
}
if (data.actions) {
await this.fillEditorFields(data.actions, this.actionList);
}
if (data.splits) {
let idx = data.actions?.length ?? 0;
for (const splitActions of data.splits) {
await this.splitIntoMultipleTransactionsButton.click();
await this.fillEditorFields(splitActions, this.actionList.nth(idx));
idx++;
}
}
}
async fillEditorFields(
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
rootElement: Locator,
fieldFirst = false,
) {
for (const [idx, entry] of data.entries()) {
const { field, op, value } = entry;
const row = await this.getRow(rootElement, idx);
if (!(await row.isVisible())) {
await this.addEntry(rootElement);
}
if (op && !fieldFirst) {
await this.selectOp(row, op);
}
if (field) {
await this.selectField(row, field);
}
if (op && fieldFirst) {
await this.selectOp(row, op);
}
if (value && value.length > 0) {
const input = row.getByRole('textbox');
const existingValue = await input.inputValue();
if (existingValue) {
await input.selectText();
}
// Using pressSequentially here to simulate user typing.
// When using .fill(...), playwright just "pastes" the entire word onto the input
// and for some reason this breaks the autocomplete highlighting logic
// e.g. "Create payee" option is not being highlighted.
await input.pressSequentially(value);
await this.page.keyboard.press('Enter');
}
}
}
async selectConditionsOp(conditionsOp: string | RegExp) {
await this.conditionsOpButton.click();
const conditionsOpSelectOption =
await this.getPopoverSelectOption(conditionsOp);
await conditionsOpSelectOption.click();
}
async selectOp(row: Locator, op: string) {
await row.getByTestId('op-select').getByRole('button').click();
const opSelectOption = await this.getPopoverSelectOption(op);
await opSelectOption.waitFor({ state: 'visible' });
await opSelectOption.click();
}
async selectField(row: Locator, field: string) {
await row.getByTestId('field-select').getByRole('button').click();
const fieldSelectOption = await this.getPopoverSelectOption(field);
await fieldSelectOption.waitFor({ state: 'visible' });
await fieldSelectOption.click();
}
async getRow(locator: Locator, index: number) {
return locator.getByTestId('editor-row').nth(index);
}
async addEntry(locator: Locator) {
await locator.getByRole('button', { name: 'Add entry' }).click();
}
async getPopoverSelectOption(value: string | RegExp) {
// Need to use page because popover is rendered outside of modal locator
return this.page
.locator('[data-popover]')
.getByRole('button', { name: value, exact: true });
}
async save() {
await this.saveButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
}

View File

@@ -0,0 +1,28 @@
import { type Locator, type Page } from '@playwright/test';
export class MobileBankSyncPage {
readonly page: Page;
readonly searchBox: Locator;
readonly accountsList: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder(/Filter accounts/i);
this.accountsList = page.getByRole('main');
}
async waitFor(options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
timeout?: number;
}) {
await this.accountsList.waitFor(options);
}
async waitToLoad() {
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
}
async searchFor(term: string) {
await this.searchBox.fill(term);
}
}

View File

@@ -2,7 +2,9 @@ import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBankSyncPage } from './mobile-bank-sync-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobilePayeesPage } from './mobile-payees-page';
import { MobileReportsPage } from './mobile-reports-page';
import { MobileRulesPage } from './mobile-rules-page';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
@@ -14,6 +16,7 @@ const NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Schedules',
'Payees',
'Rules',
'Bank Sync',
'Settings',
];
const ROUTES_BY_PAGE = {
@@ -21,7 +24,9 @@ const ROUTES_BY_PAGE = {
Accounts: '/accounts',
Transaction: '/transactions/new',
Reports: '/reports',
Payees: '/payees',
Rules: '/rules',
'Bank Sync': '/bank-sync',
Settings: '/settings',
};
@@ -166,6 +171,13 @@ export class MobileNavigation {
);
}
async goToPayeesPage() {
return await this.navigateToPage(
'Payees',
() => new MobilePayeesPage(this.page),
);
}
async goToRulesPage() {
return await this.navigateToPage(
'Rules',
@@ -173,6 +185,13 @@ export class MobileNavigation {
);
}
async goToBankSyncPage() {
return await this.navigateToPage(
'Bank Sync',
() => new MobileBankSyncPage(this.page),
);
}
async goToSettingsPage() {
return await this.navigateToPage(
'Settings',

View File

@@ -0,0 +1,75 @@
import { type Locator, type Page } from '@playwright/test';
export class MobilePayeesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly payeesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter payees…');
this.payeesList = page.getByRole('grid', { name: 'Payees' });
this.emptyMessage = page.getByText('No payees found.');
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
timeout?: number;
}) {
await this.payeesList.waitFor(options);
}
/**
* Search for payees using the search box
*/
async searchFor(text: string) {
await this.searchBox.fill(text);
}
/**
* Clear the search box
*/
async clearSearch() {
await this.searchBox.fill('');
}
/**
* Get the nth payee item (0-based index)
*/
getNthPayee(index: number) {
return this.getAllPayees().nth(index);
}
/**
* Get all visible payee items
*/
getAllPayees() {
return this.payeesList.getByRole('gridcell');
}
/**
* Click on a payee to view/edit rules
*/
async clickPayee(index: number) {
const payee = this.getNthPayee(index);
await payee.click();
}
/**
* Get the number of visible payees
*/
async getPayeeCount() {
const payees = this.getAllPayees();
return await payees.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -73,28 +73,6 @@ export class MobileRulesPage {
return await rules.count();
}
/**
* Check if the search bar has a border
*/
async hasSearchBarBorder() {
const searchContainer = this.searchBox.locator('..');
const borderStyle = await searchContainer.evaluate(el => {
const style = window.getComputedStyle(el);
return style.borderBottomWidth;
});
return borderStyle === '2px';
}
/**
* Get the background color of the search box
*/
async getSearchBackgroundColor() {
return await this.searchBox.evaluate(el => {
const style = window.getComputedStyle(el);
return style.backgroundColor;
});
}
/**
* Check if a rule contains specific text
*/

View File

@@ -1,6 +1,7 @@
import { type Page } from '@playwright/test';
import { AccountPage } from './account-page';
import { BankSyncPage } from './bank-sync-page';
import { PayeesPage } from './payees-page';
import { ReportsPage } from './reports-page';
import { RulesPage } from './rules-page';
@@ -66,6 +67,19 @@ export class Navigation {
return new PayeesPage(this.page);
}
async goToBankSyncPage() {
const bankSyncLink = this.page.getByRole('link', { name: 'Bank Sync' });
// Expand the "more" menu only if it is not already expanded
if (!(await bankSyncLink.isVisible())) {
await this.page.getByRole('button', { name: 'More' }).click();
}
await bankSyncLink.click();
return new BankSyncPage(this.page);
}
async goToSettingsPage() {
const settingsLink = this.page.getByRole('link', { name: 'Settings' });

View File

@@ -1,52 +1,26 @@
import { type Locator, type Page } from '@playwright/test';
type ConditionsEntry = {
field: string;
op: string;
value: string;
};
type ActionsEntry = {
field: string;
op?: string;
value: string;
};
type SplitsEntry = {
field: string;
op?: string;
value?: string;
};
type RuleEntry = {
conditionsOp?: string | RegExp;
conditions?: ConditionsEntry[];
actions?: ActionsEntry[];
splits?: Array<SplitsEntry[]>;
};
import { EditRuleModal } from './edit-rule-modal';
export class RulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly createNewRuleButton: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules...');
this.createNewRuleButton = page.getByRole('button', {
name: 'Create new rule',
});
}
/**
* Create a new rule
* Open the edit rule modal to create a new rule.
*/
async createRule(data: RuleEntry) {
await this.page
.getByRole('button', {
name: 'Create new rule',
})
.click();
await this._fillRuleFields(data);
await this.page.getByRole('button', { name: 'Save' }).click();
async createNewRule() {
await this.createNewRuleButton.click();
return new EditRuleModal(this.page.getByTestId('edit-rule-modal'));
}
/**
@@ -65,108 +39,4 @@ export class RulesPage {
async searchFor(text: string) {
await this.searchBox.fill(text);
}
async _fillRuleFields(data: RuleEntry) {
if (data.conditionsOp) {
await this.page
.getByTestId('conditions-op')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('button', { exact: true, name: data.conditionsOp })
.click();
}
if (data.conditions) {
await this._fillEditorFields(
data.conditions,
this.page.getByTestId('condition-list'),
true,
);
}
if (data.actions) {
await this._fillEditorFields(
data.actions,
this.page.getByTestId('action-list'),
);
}
if (data.splits) {
let idx = data.actions?.length ?? 0;
for (const splitActions of data.splits) {
await this.page.getByTestId('add-split-transactions').click();
await this._fillEditorFields(
splitActions,
this.page.getByTestId('action-list').nth(idx),
);
idx++;
}
}
}
async _fillEditorFields(
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
rootElement: Locator,
fieldFirst = false,
) {
for (const [idx, entry] of data.entries()) {
const { field, op, value } = entry;
const row = rootElement.getByTestId('editor-row').nth(idx);
if (!(await row.isVisible())) {
await rootElement.getByRole('button', { name: 'Add entry' }).click();
}
if (op && !fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (field) {
await row
.getByTestId('field-select')
.getByRole('button')
.first()
.click();
await this.page
.getByRole('button', { name: field, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: field, exact: true })
.first()
.click({ force: true });
}
if (op && fieldFirst) {
await row.getByTestId('op-select').getByRole('button').first().click();
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.waitFor({ state: 'visible' });
await this.page
.getByRole('button', { name: op, exact: true })
.first()
.click({ force: true });
}
if (value) {
await row.getByRole('textbox').fill(value);
await this.page.keyboard.press('Enter');
}
}
}
}

View File

@@ -0,0 +1,78 @@
import { type Page, type Locator } from '@playwright/test';
type ScheduleEntry = {
scheduleName?: string;
payee?: string;
account?: string;
amount?: number;
};
export class ScheduleEditModal {
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly scheduleNameInput: Locator;
readonly payeeInput: Locator;
readonly accountInput: Locator;
readonly amountInput: Locator;
readonly addButton: Locator;
readonly saveButton: Locator;
readonly cancelButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.scheduleNameInput = locator.getByRole('textbox', {
name: 'Schedule name',
});
this.payeeInput = locator.getByRole('textbox', { name: 'Payee' });
this.accountInput = locator.getByRole('textbox', { name: 'Account' });
this.amountInput = locator.getByLabel('Amount');
this.addButton = locator.getByRole('button', { name: 'Add' });
this.saveButton = locator.getByRole('button', { name: 'Save' });
this.cancelButton = locator.getByRole('button', { name: 'Cancel' });
}
async fill(data: ScheduleEntry) {
// Using pressSequentially on autocomplete fields here to simulate user typing.
// When using .fill(...), playwright just "pastes" the entire word onto the input
// and for some reason this breaks the autocomplete highlighting logic
// e.g. "Create payee" option is not being highlighted.
if (data.scheduleName) {
await this.scheduleNameInput.fill(data.scheduleName);
}
if (data.payee) {
await this.payeeInput.pressSequentially(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.accountInput.pressSequentially(data.account);
await this.page.keyboard.press('Enter');
}
if (data.amount) {
await this.amountInput.fill(String(data.amount));
}
}
async save() {
await this.saveButton.click();
}
async add() {
await this.addButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
}

View File

@@ -1,10 +1,6 @@
import { type Locator, type Page } from '@playwright/test';
type ScheduleEntry = {
payee?: string;
account?: string;
amount?: number;
};
import { ScheduleEditModal } from './schedule-edit-modal';
export class SchedulesPage {
readonly page: Page;
@@ -21,17 +17,12 @@ export class SchedulesPage {
}
/**
* Add a new schedule
* Open the schedule edit modal.
*/
async addNewSchedule(data: ScheduleEntry) {
async addNewSchedule() {
await this.addNewScheduleButton.click();
await this._fillScheduleFields(data);
await this.page
.getByTestId('schedule-edit-modal')
.getByRole('button', { name: 'Add' })
.click();
return new ScheduleEditModal(this.page.getByTestId('schedule-edit-modal'));
}
/**
@@ -83,26 +74,4 @@ export class SchedulesPage {
await actions.getByRole('button').click();
await this.page.getByRole('button', { name: actionName }).click();
}
async _fillScheduleFields(data: ScheduleEntry) {
if (data.payee) {
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.page
.getByRole('textbox', { name: 'Account' })
.fill(data.account);
await this.page.keyboard.press('Enter');
}
if (data.amount) {
await this.page.getByLabel('Amount').fill(String(data.amount));
// For some readon, the input field does not trigger the change event on tests
// but it works on the browser. We can revisit this once migration to
// react aria components is complete.
await this.page.keyboard.press('Enter');
}
}
}

View File

@@ -0,0 +1,112 @@
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import { MobileNavigation } from './page-models/mobile-navigation';
import { type MobilePayeesPage } from './page-models/mobile-payees-page';
test.describe('Mobile Payees', () => {
let page: Page;
let navigation: MobileNavigation;
let payeesPage: MobilePayeesPage;
let configurationPage: ConfigurationPage;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
navigation = new MobileNavigation(page);
configurationPage = new ConfigurationPage(page);
// Set mobile viewport
await page.setViewportSize({
width: 350,
height: 600,
});
await page.goto('/');
await configurationPage.createTestFile();
// Navigate to payees page and wait for it to load
payeesPage = await navigation.goToPayeesPage();
});
test.afterEach(async () => {
await page.close();
});
test('checks the page visuals', async () => {
await payeesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(page.getByRole('heading', { name: 'Payees' })).toBeVisible();
// Check that the search box is present with proper placeholder
await expect(payeesPage.searchBox).toBeVisible();
await expect(payeesPage.searchBox).toHaveAttribute(
'placeholder',
'Filter payees…',
);
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await expect(page).toMatchThemeScreenshots();
});
test('filters out unrelated payees', async () => {
await payeesPage.searchFor('asdfasdf-nonsense');
// Get the text 'No payees found.' from the page
const noPayeesMessage = page.getByText('No payees found.');
// Assert it is visible
await expect(noPayeesMessage).toBeVisible();
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a payee opens rule creation form', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await payeesPage.clickPayee(0);
// Should navigate to rules page for creating a new rule
await expect(page).toHaveURL(/\/rules/);
await expect(page).toMatchThemeScreenshots();
});
test('page handles empty state gracefully', async () => {
// Search for something that won't match to get empty state
await payeesPage.searchFor('NonExistentPayee123456789');
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText('No payees found.');
await expect(emptyMessage).toBeVisible();
// Check that no payee items are visible
const payees = payeesPage.getAllPayees();
await expect(payees).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
test('search functionality works correctly', async () => {
await payeesPage.waitForLoadingToComplete();
// Test searching for a specific payee
await payeesPage.searchFor('Fast Internet');
// Should show at least one result
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
await expect(page).toMatchThemeScreenshots();
// Clear search
await payeesPage.clearSearch();
// Should show all payees again
const allPayeeCount = await payeesPage.getPayeeCount();
expect(allPayeeCount).toBeGreaterThan(payeeCount);
await expect(page).toMatchThemeScreenshots();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Some files were not shown because too many files have changed in this diff Show More