Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions[bot]
88a8729071 [AI] Simplify CLI cache/lock internals
Use a discriminated SyncDecision union so connection.ts no longer needs
non-null assertions on the cached state. Thread the resolved CliConfig
through withConnection's callback to drop duplicate resolveConfig calls
in the sync and budgets commands. Extract an errorCode helper and
replace the existsSync+readdirSync TOCTOU pattern in the reader-wait
polling loop with a single readdir that tolerates ENOENT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:13:50 +01:00
github-actions[bot]
85d601a707 [AI] Cache the CLI's local budget between invocations
Every `actual <cmd>` call currently delta-syncs the budget with the
sync server via `api.downloadBudget`, which hits the server's
500-req/min rate limit on scripted workflows. Actual is local-first:
once the budget is on disk, most read commands do not need fresh
server data.

Introduce a CLI-only cache layer inside `withConnection` that
decides per invocation whether to skip, sync, or re-download:

- Cache state lives at `{dataDir}/.actual-cli/{syncId}/state.json`,
  keyed by `syncId` to avoid the chicken-and-egg of not knowing the
  on-disk `budgetId` before the first download. The on-disk id is
  resolved via `api.getBudgets()` and persisted after first download.
- Read commands (list, balance, query run, …) skip the `/sync`
  call while `now - lastSyncedAt < cacheTtl`. Write commands
  (create, update, delete, set-*, etc.) sync before and after the
  operation to keep server state consistent.
- Encrypted budgets force a sync per call since `api/load-budget`
  does not re-verify the password.
- New `proper-lockfile`-backed shared/exclusive lock serializes
  writes while allowing parallel reads. Reader markers live in
  `{meta}/readers/`; writers sweep stale markers by PID.

New `actual sync` command with three modes: default (sync now),
`--status` (print cache age, TTL, stale flag), `--clear` (delete
cache, holding the exclusive lock to avoid racing writers).

New config surface, following the existing flag → env → config file
→ default precedence chain:

- `--cache-ttl <s>` / `ACTUAL_CACHE_TTL` / `cacheTtl` (default 60)
- `--refresh` / `--no-cache`
- `--lock-timeout <s>` / `ACTUAL_LOCK_TIMEOUT` / `lockTimeout` (10)
- `--no-lock` / `ACTUAL_NO_LOCK` / `noLock`

Every `withConnection` call site now passes an explicit
`{ mutates: boolean, skipBudget?: boolean }` so read/write intent is
visible at the edge.

The old `budgets sync` subcommand is removed — it silently diverged
from the new top-level `actual sync`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:03:40 +01:00
358 changed files with 4595 additions and 10974 deletions

View File

@@ -1,6 +1,6 @@
issue_enrichment:
auto_enrich:
enabled: true
enabled: false
reviews:
request_changes_workflow: true
review_status: false

View File

@@ -1,4 +1,4 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
## Description

View File

@@ -61,7 +61,6 @@ Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ecf
EDATE
ENTERCARD
Entra
@@ -141,6 +140,8 @@ pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
Qatari
QNTOFRP
@@ -171,6 +172,7 @@ SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB

View File

@@ -9,7 +9,7 @@ runs:
node-version: 22
- name: Install dependencies
shell: bash
run: yarn workspaces focus actual @actual-app/ci-actions
run: yarn workspaces focus @actual-app/ci-actions
- name: Generate release notes
shell: bash
env:

View File

@@ -52,9 +52,8 @@ runs:
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
persist-credentials: false
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
if: ${{ inputs.download-translations == 'true' }}
- name: Remove untranslated languages
run: packages/desktop-client/bin/remove-untranslated-languages
shell: bash
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
if: ${{ inputs.download-translations == 'true' }}

View File

@@ -18,8 +18,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -16,8 +16,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -19,26 +19,10 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
setup:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
api:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -61,12 +45,9 @@ jobs:
path: api-stats.json
crdt:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -89,12 +70,9 @@ jobs:
path: crdt-stats.json
web:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
@@ -111,12 +89,9 @@ jobs:
path: packages/desktop-client/build-stats
cli:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -139,12 +114,9 @@ jobs:
path: cli-stats.json
server:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -12,40 +12,20 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
setup:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
constraints:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Check dependency version consistency
run: yarn constraints
- name: Check tsconfig project references are in sync
run: yarn check:tsconfig-references
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -53,12 +33,9 @@ jobs:
- name: Lint
run: yarn lint
typecheck:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -66,12 +43,9 @@ jobs:
- name: Typecheck
run: yarn typecheck
validate-cli:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -81,12 +55,9 @@ jobs:
- name: Check that the built CLI works
run: node packages/sync-server/build/bin/actual-server.js --version
test:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -104,13 +75,10 @@ jobs:
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
migrations:
needs: setup
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -23,8 +23,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1

View File

@@ -17,8 +17,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -31,7 +31,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref || 'master' }}
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -37,8 +37,6 @@ jobs:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

View File

@@ -29,8 +29,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

View File

@@ -17,80 +17,32 @@ on:
env:
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-web:
name: Build web bundle
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Build browser bundle
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
# bundle so the "Create test file" button (used by every e2e beforeEach
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
retention-days: 1
overwrite: true
functional:
name: Functional (shard ${{ matrix.shard }}/3)
name: Functional (shard ${{ matrix.shard }}/5)
runs-on: ubuntu-latest
needs: build-web
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
env:
E2E_USE_BUILD: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run E2E Tests
run: yarn e2e --shard=${{ matrix.shard }}/3
run: yarn e2e --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
if: always()
with:
name: desktop-client-test-results-shard-${{ matrix.shard }}
path: packages/desktop-client/test-results/
@@ -104,8 +56,6 @@ jobs:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -130,34 +80,22 @@ jobs:
overwrite: true
vrt:
name: Visual regression (shard ${{ matrix.shard }}/3)
name: Visual regression (shard ${{ matrix.shard }}/5)
runs-on: ubuntu-latest
needs: build-web
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
env:
E2E_USE_BUILD: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Trust the repository directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Download web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: desktop-client-build
path: packages/desktop-client/build/
- name: Run VRT Tests
run: yarn vrt --shard=${{ matrix.shard }}/3
run: yarn vrt --shard=${{ matrix.shard }}/5
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
@@ -175,8 +113,6 @@ jobs:
image: mcr.microsoft.com/playwright:v1.59.1-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
- name: Download all blob reports
@@ -201,9 +137,7 @@ jobs:
mkdir -p vrt-metadata
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
echo "${STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL}" > vrt-metadata/artifact-url.txt
env:
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
- name: Upload VRT metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -31,8 +31,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -59,11 +57,9 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=${STEPS_PROCESS_VERSION_OUTPUTS_VERSION}
VERSION=${{ steps.process_version.outputs.version }}
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
env:
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac

View File

@@ -35,8 +35,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}

View File

@@ -15,7 +15,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: actual
persist-credentials: false
- name: Set up environment
uses: ./actual/.github/actions/setup
with:
@@ -60,8 +59,6 @@ jobs:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations
path: translations
# Need to be able to push back extracted strings
persist-credentials: true
- name: Generate i18n strings
working-directory: actual
run: |

View File

@@ -25,8 +25,6 @@ jobs:
steps:
# This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22

View File

@@ -22,8 +22,6 @@ jobs:
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -1,47 +0,0 @@
name: Nightly theme catalog scan
on:
schedule:
# 05:15 UTC daily — runs after the i18n extract job (04:00) and well
# before the nightly Electron/npm publishes (00:00 UTC the next day).
- cron: '15 5 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
validate-theme-catalog:
name: Validate custom theme catalog
runs-on: ubuntu-latest
if: github.repository == 'actualbudget/actual'
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Validate themes
run: yarn workspace @actual-app/web validate:theme-catalog
notify-failure:
name: Notify Discord on failure
needs: validate-theme-catalog
if: failure() && github.repository == 'actualbudget/actual'
runs-on: ubuntu-latest
environment: nightly-alerts
timeout-minutes: 5
steps:
- name: Notify Discord
uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
status: Failure
title: Nightly theme catalog scan failed
description: The nightly scan failed. One or more themes may be broken, or the scan itself did not complete.
username: Actual Nightly
nofail: true

View File

@@ -54,9 +54,8 @@ jobs:
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
run: |
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
TAG="${{ steps.resolve_version.outputs.tag }}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
@@ -78,7 +77,7 @@ jobs:
- name: Calculate AppImage SHA256 (streamed)
run: |
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}"
VERSION="${{ steps.resolve_version.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
echo "Streaming x86_64 AppImage to compute SHA256..."
@@ -91,32 +90,27 @@ jobs:
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
env:
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
- name: Checkout Flathub repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
persist-credentials: false
- name: Update manifest with new version
run: |
VERSION="${STEPS_RESOLVE_VERSION_OUTPUTS_VERSION}"
VERSION="${{ steps.resolve_version.outputs.version }}"
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_X64_SHA256}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${APPIMAGE_ARM64_SHA256}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
echo "Updated manifest:"
cat com.actualbudget.actual.yml
env:
STEPS_RESOLVE_VERSION_OUTPUTS_VERSION: ${{ steps.resolve_version.outputs.version }}
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1

View File

@@ -30,8 +30,6 @@ jobs:
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools

View File

@@ -0,0 +1,124 @@
name: Publish nightly npm packages
# Nightly npm packages are built daily at midnight UTC
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
build-and-pack:
runs-on: ubuntu-latest
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
- name: Yarn install
run: |
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
run: yarn build:server
- name: Pack the web and server packages
run: |
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
- name: Build API
run: yarn build:api
- name: Pack the api package
run: |
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Build CLI
run: yarn workspace @actual-app/cli build
- name: Pack the cli package
run: |
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
packages/cli/@actual-app/cli.tgz
publish:
runs-on: ubuntu-latest
name: Publish Nightly npm packages
needs: build-and-pack
permissions:
contents: read
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server
run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API
run: |
npm publish api/@actual-app/api.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,55 +1,26 @@
name: Publish npm packages
# Npm packages are published for every new tag and nightly schedule
# # Npm packages are published for every new tag
on:
push:
tags:
- 'v*.*.*'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
build-and-pack:
runs-on: ubuntu-latest
name: Build and pack npm packages
if: github.event_name == 'push' || (github.event.repository.fork == false)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
if: github.event_name != 'push'
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
- name: Yarn install
if: github.event_name != 'push'
run: |
# Required after nightly `npm version` updates workspace manifests.
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
- name: Build Web
run: yarn build:server
- name: Pack the web and server packages
@@ -73,7 +44,6 @@ jobs:
- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !env.ACT }}
with:
name: npm-packages
path: |
@@ -90,9 +60,6 @@ jobs:
permissions:
contents: read
packages: write
id-token: write # Required for OIDC
env:
NPM_DIST_TAG: ${{ github.event_name != 'push' && 'nightly' || '' }}
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -102,26 +69,35 @@ jobs:
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
check-latest: true
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
npm publish desktop-client/@actual-app/web.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server
run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
npm publish sync-server/@actual-app/sync-server.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API
run: |
npm publish api/@actual-app/api.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
npm publish api/@actual-app/api.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish CLI
run: |
npm publish cli/@actual-app/cli.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
npm publish cli/@actual-app/cli.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -37,15 +37,13 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
# Need to be able to commit release notes after generation
persist-credentials: true
- name: Get changed files
if: steps.bot-check.outputs.skip != 'true'
id: changed-files
run: |
git fetch origin ${GITHUB_BASE_REF}
CHANGED_FILES=$(git diff --name-only origin/${GITHUB_BASE_REF}...HEAD)
git fetch origin ${{ github.base_ref }}
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -e "^packages/docs/" -e "^\.github/actions/docs-spelling/" || true)
if [ -z "$NON_DOCS_FILES" ] && [ -n "$CHANGED_FILES" ]; then

View File

@@ -38,7 +38,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.base_ref }}
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -105,7 +104,7 @@ jobs:
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${GITHUB_BASE_REF}"
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download web build artifact from ${{github.base_ref}}

View File

@@ -3,12 +3,9 @@ on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch: # Allow manual triggering
permissions: {}
jobs:
stale:
permissions:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
@@ -19,8 +16,6 @@ jobs:
days-before-close: 5
days-before-issue-stale: -1
stale-wip:
permissions:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
@@ -32,8 +27,6 @@ jobs:
days-before-issue-stale: -1
stale-needs-info:
permissions:
issues: write
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0

View File

@@ -107,7 +107,7 @@ jobs:
fi
# Commit
git commit -m "Update VRT screenshots" -m "Auto-generated by VRT workflow" -m "PR: #${STEPS_METADATA_OUTPUTS_PR_NUMBER}"
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
@@ -116,8 +116,6 @@ jobs:
echo "error=Patch conflicts with current branch state" >> "$GITHUB_OUTPUT"
exit 1
fi
env:
STEPS_METADATA_OUTPUTS_PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
- name: Push changes
if: steps.apply.outputs.applied == 'true'

View File

@@ -63,7 +63,6 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Set up environment
uses: ./.github/actions/setup
@@ -130,11 +129,8 @@ jobs:
run: |
mkdir -p pr-metadata
echo "${{ github.event.issue.number }}" > pr-metadata/pr-number.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REF}" > pr-metadata/head-ref.txt
echo "${STEPS_PR_OUTPUTS_HEAD_REPO}" > pr-metadata/head-repo.txt
env:
STEPS_PR_OUTPUTS_HEAD_REF: ${{ steps.pr.outputs.head_ref }}
STEPS_PR_OUTPUTS_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
echo "${{ steps.pr.outputs.head_ref }}" > pr-metadata/head-ref.txt
echo "${{ steps.pr.outputs.head_repo }}" > pr-metadata/head-repo.txt
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'

3
.gitignore vendored
View File

@@ -42,9 +42,6 @@ bundle.desktop.js.map
bundle.mobile.js
bundle.mobile.js.map
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
.venv/
# Yarn
.pnp.*
.yarn/*

View File

@@ -37,7 +37,6 @@
"actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error",
"actual/prefer-subpath-imports": "error",
"actual/enforce-boundaries": "error",
"actual/no-extraneous-dependencies": "error",
// JSX A11y rules
@@ -370,14 +369,7 @@
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
"rules": {
"actual/no-untranslated-strings": "off",
"actual/prefer-logger-over-console": "off",
"typescript/unbound-method": "off"
}
},
{
"files": ["packages/eslint-plugin-actual/lib/rules/__tests__/**/*"],
"rules": {
"actual/enforce-boundaries": "off"
"actual/prefer-logger-over-console": "off"
}
},
{

View File

@@ -281,6 +281,7 @@ Always run `yarn typecheck` before committing.
- 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:**

View File

@@ -1,3 +1 @@
Please review the contributing documentation on our website: https://actualbudget.org/docs/contributing/
If you plan to use AI tools when contributing, please also read our [AI Usage Policy](https://actualbudget.org/docs/contributing/ai-usage-policy).

View File

@@ -4,30 +4,21 @@ ROOT=`dirname $0`
cd "$ROOT/.."
SKIP_TRANSLATIONS=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-translations)
SKIP_TRANSLATIONS=true
shift
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
if [ "$SKIP_TRANSLATIONS" = false ]; then
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
lage build:browser --to=@actual-app/web
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace @actual-app/crdt build
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"

View File

@@ -57,7 +57,8 @@ yarn workspace @actual-app/core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn build:browser
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build

View File

@@ -25,14 +25,6 @@ module.exports = {
outputGlob: BUILD_OUTPUT_GLOBS,
},
},
// Not cached: the script stages files into public/ and build-stats/ that
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
// effects.
'build:browser': {
type: 'npmScript',
dependsOn: ['^build'],
cache: false,
},
},
cacheOptions: {
cacheStorageConfig: {

View File

@@ -24,16 +24,18 @@
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build": "lage build",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -59,16 +61,13 @@
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"check:tsconfig-references": "workspaces-to-typescript-project-references --check",
"sync:tsconfig-references": "workspaces-to-typescript-project-references",
"prepare": "husky"
},
"devDependencies": {
"@monorepo-utils/workspaces-to-typescript-project-references": "^2.10.3",
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.17",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@yarnpkg/types": "^4.0.1",
"eslint": "^10.2.0",
"eslint-plugin-perfectionist": "^5.8.0",
@@ -99,9 +98,8 @@
"socks": ">=2.8.3"
},
"lint-staged": {
"packages/*/{package.json,tsconfig.json}": [
"ts-node ./bin/validate-publish-imports.ts --fix",
"yarn sync:tsconfig-references"
"packages/*/package.json": [
"ts-node ./bin/validate-publish-imports.ts --fix"
],
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
"oxfmt --no-error-on-unmatched-pattern"

View File

@@ -6,11 +6,6 @@ import { vi } from 'vitest';
import * as api from './index';
declare global {
var IS_TESTING: boolean;
var currentMonth: string | null;
}
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(

View File

@@ -1 +0,0 @@
export type * from '@actual-app/core/server/api-models';

View File

@@ -1,18 +1,11 @@
{
"name": "@actual-app/api",
"version": "26.5.0",
"version": "26.4.0",
"description": "An API for Actual",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/api"
},
"files": [
"@types",
"dist",
"!@types/**/*.test.d.ts",
"!@types/**/*.test.d.ts.map"
"dist"
],
"main": "dist/index.js",
"types": "@types/index.d.ts",
@@ -21,11 +14,6 @@
"types": "./@types/index.d.ts",
"development": "./index.ts",
"default": "./dist/index.js"
},
"./models": {
"types": "./@types/models.d.ts",
"development": "./models.ts",
"default": "./dist/models.js"
}
},
"publishConfig": {
@@ -33,10 +21,6 @@
".": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
},
"./models": {
"types": "./@types/models.d.ts",
"default": "./dist/models.js"
}
}
},
@@ -52,7 +36,7 @@
"compare-versions": "^6.1.1"
},
"devDependencies": {
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",

View File

@@ -15,26 +15,15 @@
"rootDir": ".",
"declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"plugins": [
{
"name": "typescript-strict-plugin",
"paths": ["."]
}
]
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"references": [
{
"path": "../loot-core"
},
{
"path": "../crdt"
}
],
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": [
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.config.mts"
]

View File

@@ -66,12 +66,9 @@ export default defineConfig({
emptyOutDir: true,
sourcemap: true,
lib: {
entry: {
index: path.resolve(__dirname, 'index.ts'),
models: path.resolve(__dirname, 'models.ts'),
},
entry: path.resolve(__dirname, 'index.ts'),
formats: ['cjs'],
fileName: (_format, entryName) => `${entryName}.js`,
fileName: () => 'index.js',
},
},
plugins: [

View File

@@ -69,8 +69,6 @@ const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
await exec(`git config user.name '${botName}'`);
await exec(`git config user.email '${botEmail}'`);
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
await group('Prepare branch', async () => {
if (process.env.GITHUB_HEAD_REF) {
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
@@ -81,34 +79,17 @@ await group('Prepare branch', async () => {
});
}
// recover deleted release note files from previous generation commits
const baseRef = process.env.GITHUB_BASE_REF || 'master';
await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' });
const { stdout: mergeBase } = await exec(
`git merge-base HEAD origin/${baseRef}`,
// the previous generation commit deletes source files from
// upcoming-release-notes, rebase it out so we can regenerate from all of them
const { stdout: commitHash } = await exec(
`git log --grep='${commitMessage}' --format=%H -1`,
);
const base = mergeBase.trim();
const { stdout: genLog } = await exec(
`git log --grep='${commitMessage}' --format=%H ${base}..HEAD`,
);
const genCommits = genLog.split('\n').filter(Boolean);
console.log(
`Reversing upcoming-release-notes deletions from ${genCommits.length} prior generation commit(s)`,
);
const tmpDir = process.env.RUNNER_TEMP || '/tmp';
for (const sha of genCommits) {
const patchPath = join(tmpDir, `revert-${sha}.patch`);
try {
await exec(
`git diff --diff-filter=D ${sha}~1..${sha} -- upcoming-release-notes > ${patchPath}`,
);
const { size } = await fs.stat(patchPath);
if (size > 0) {
await exec(`git apply -R --3way ${patchPath}`, { stdio: 'inherit' });
}
} finally {
await fs.unlink(patchPath).catch(() => undefined);
}
const hash = commitHash.trim();
if (hash) {
console.log(`Dropping previous release notes commit ${hash}`);
await exec(`git rebase --onto ${hash}~1 ${hash}`, {
stdio: 'inherit',
});
}
});
@@ -126,14 +107,13 @@ if (files.length === 0) {
const highlights = '- TODO: Add release highlights';
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const releasesPath = 'packages/docs/docs/releases.md';
await group('Generate blog post', async () => {
const template = `---
const blogPath = join(
'packages/docs/blog',
`${releaseDate}-release-${slug}.md`,
);
const blogContent = `---
title: Release ${version}
description: New release of Actual.
date: ${releaseDate}T10:00
@@ -149,60 +129,18 @@ ${highlights}
**Docker Tag: ${version}**
${AUTOGEN_MARKER}
${categorizedNotes}
`;
let blogContent;
try {
const existing = await fs.readFile(blogPath, 'utf-8');
const idx = existing.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: ${blogPath} missing ${AUTOGEN_MARKER}, rewriting from template`,
);
blogContent = template;
} else {
blogContent =
existing.slice(0, idx + AUTOGEN_MARKER.length) +
'\n' +
categorizedNotes +
'\n';
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
blogContent = template;
}
await fs.writeFile(blogPath, blogContent);
console.log(`Wrote ${blogPath}`);
});
await group('Update releases.md', async () => {
const releasesPath = 'packages/docs/docs/releases.md';
const existing = await fs.readFile(releasesPath, 'utf-8');
const sectionRe = new RegExp(
`(^|\\n)## ${escapeRegExp(version)}\\n[\\s\\S]*?(?=\\n## |$)`,
);
const match = existing.match(sectionRe);
let updated;
if (match) {
const section = match[0];
const idx = section.indexOf(AUTOGEN_MARKER);
if (idx === -1) {
console.log(
`WARNING: section for ${version} in ${releasesPath} missing ${AUTOGEN_MARKER}, leaving as-is`,
);
updated = existing;
} else {
const newSection =
section.slice(0, idx + AUTOGEN_MARKER.length) + '\n' + categorizedNotes;
updated = existing.replace(section, newSection);
}
} else {
const newSection = `## ${version}
const newSection = `## ${version}
Release date: ${releaseDate}
@@ -210,14 +148,12 @@ ${highlights}
**Docker Tag: ${version}**
${AUTOGEN_MARKER}
${categorizedNotes}`;
updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
}
const updated = existing.replace(
'# Release Notes\n',
`# Release Notes\n\n${newSection}\n`,
);
await fs.writeFile(releasesPath, updated);
console.log(`Updated ${releasesPath}`);
@@ -229,28 +165,13 @@ await group('Remove used release notes', async () => {
);
});
await group('Format generated files', async () => {
await exec(`yarn exec oxfmt ${blogPath} ${releasesPath}`, {
stdio: 'inherit',
});
});
await group('Commit and push', async () => {
await exec(
'git add upcoming-release-notes packages/docs/blog packages/docs/docs/releases.md',
{ stdio: 'inherit' },
);
try {
await exec('git diff --cached --quiet');
console.log('No changes to commit');
return;
} catch {
// there are staged changes
}
await exec(`git commit -m '${commitMessage}'`);
await exec('git push origin', { stdio: 'inherit' });
await exec('git push --force-with-lease origin', { stdio: 'inherit' });
});
async function parseReleaseNotes(dir) {
@@ -284,10 +205,6 @@ async function parseReleaseNotes(dir) {
return { notesByCategory, files };
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function formatNotes(notes) {
return Object.entries(notes)
.filter(([_, values]) => values.length > 0)

View File

@@ -9,7 +9,7 @@
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"extensionless": "^2.0.6",
"gray-matter": "^4.0.3",
"listify": "^1.0.3",

View File

@@ -1,13 +1,8 @@
{
"name": "@actual-app/cli",
"version": "26.5.0",
"version": "26.4.0",
"description": "CLI for Actual Budget",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/cli"
},
"bin": {
"actual": "./dist/cli.js",
"actual-cli": "./dist/cli.js"
@@ -41,7 +36,7 @@
"devDependencies": {
"@types/node": "^22.19.17",
"@types/proper-lockfile": "^4",
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"rollup-plugin-visualizer": "^7.0.1",
"vite": "^8.0.5",
"vitest": "^4.1.2"

View File

@@ -1,4 +1,3 @@
import { randomBytes } from 'node:crypto';
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -57,11 +56,7 @@ export function writeCacheState(metaDir: string, state: CacheState): void {
try {
mkdirSync(metaDir, { recursive: true });
const target = cachePath(metaDir);
// Unique tmp name per writer: concurrent shared-lock commands (encrypted
// budgets, --refresh, stale TTL) can both publish, and a shared tmp path
// lets the second writer's truncate destroy the first writer's bytes
// before either renames into place.
const tmp = `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
const tmp = `${target}.tmp`;
writeFileSync(tmp, JSON.stringify(state));
renameSync(tmp, target);
} catch {

View File

@@ -33,7 +33,7 @@ export function registerBudgetsCommand(program: Command) {
opts,
async config => {
const password =
cmdOpts.encryptionPassword ?? config.encryptionPassword;
config.encryptionPassword ?? cmdOpts.encryptionPassword;
await api.downloadBudget(syncId, {
password,
});

View File

@@ -57,10 +57,10 @@ export function registerSyncCommand(program: Command) {
);
return;
}
const rawAgeSeconds = Math.round(
(Date.now() - state.lastSyncedAt) / 1000,
const ageSeconds = Math.max(
0,
Math.round((Date.now() - state.lastSyncedAt) / 1000),
);
const ageSeconds = Math.max(0, rawAgeSeconds);
printOutput(
{
neverSynced: false,
@@ -70,7 +70,7 @@ export function registerSyncCommand(program: Command) {
lastDownloadedAt: new Date(state.lastDownloadedAt).toISOString(),
ageSeconds,
ttlSeconds: config.cacheTtl,
stale: rawAgeSeconds < 0 || rawAgeSeconds > config.cacheTtl,
stale: ageSeconds > config.cacheTtl,
},
opts.format,
);

View File

@@ -215,31 +215,16 @@ describe('resolveConfig', () => {
expect(config.refresh).toBe(true);
});
it('sets refresh when --no-cache is passed (cliOpts.cache === false)', async () => {
const config = await resolveConfig({ cache: false });
it('sets refresh when noCache is true', async () => {
const config = await resolveConfig({ noCache: true });
expect(config.refresh).toBe(true);
});
it('does not set refresh when cliOpts.cache is true (flag absent)', async () => {
const config = await resolveConfig({ cache: true });
expect(config.refresh).toBe(false);
});
it('defaults noLock to false', async () => {
const config = await resolveConfig({});
expect(config.noLock).toBe(false);
});
it('sets noLock when --no-lock is passed (cliOpts.lock === false)', async () => {
const config = await resolveConfig({ lock: false });
expect(config.noLock).toBe(true);
});
it('leaves noLock false when cliOpts.lock is true (flag absent)', async () => {
const config = await resolveConfig({ lock: true });
expect(config.noLock).toBe(false);
});
it('parses ACTUAL_NO_LOCK=1 as true', async () => {
process.env.ACTUAL_NO_LOCK = '1';
const config = await resolveConfig({});
@@ -252,11 +237,6 @@ describe('resolveConfig', () => {
expect(config.noLock).toBe(true);
});
it('throws on an invalid ACTUAL_NO_LOCK value', async () => {
process.env.ACTUAL_NO_LOCK = 'yes';
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_NO_LOCK/);
});
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
mockConfigFile({
serverUrl: 'http://file',

View File

@@ -28,10 +28,8 @@ export type CliGlobalOpts = {
cacheTtl?: number;
lockTimeout?: number;
refresh?: boolean;
// Commander stores --no-foo flags under the positive key. Default true,
// false when the flag is passed.
cache?: boolean;
lock?: boolean;
noCache?: boolean;
noLock?: boolean;
format?: 'json' | 'table' | 'csv';
verbose?: boolean;
};
@@ -210,12 +208,11 @@ export async function resolveConfig(
'lockTimeout',
);
const refresh = (cliOpts.refresh ?? false) || cliOpts.cache === false;
const refresh = cliOpts.refresh ?? cliOpts.noCache ?? false;
const flagNoLock = cliOpts.lock === false ? true : undefined;
const noLock =
flagNoLock ??
parseBoolEnv(process.env.ACTUAL_NO_LOCK, 'ACTUAL_NO_LOCK') ??
cliOpts.noLock ??
parseBoolEnv(process.env.ACTUAL_NO_LOCK) ??
fileConfig.noLock ??
false;

View File

@@ -23,11 +23,14 @@ function info(message: string, verbose?: boolean) {
}
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
const budgets = await api.getBudgets();
const budgets = (await api.getBudgets()) as Array<{
id?: string;
groupId?: string;
cloudFileId?: string;
}>;
const match = budgets.find(
b =>
typeof b.id === 'string' &&
(b.groupId === syncId || b.cloudFileId === syncId),
b.id !== undefined && (b.groupId === syncId || b.cloudFileId === syncId),
);
if (!match?.id) {
throw new Error(

View File

@@ -40,7 +40,7 @@ program
value => parseNonNegativeIntFlag(value, '--cache-ttl'),
)
.option('--refresh', 'Force a sync on this call, ignoring the cache', false)
.option('--no-cache', 'Alias for --refresh')
.option('--no-cache', 'Alias for --refresh', false)
.option(
'--lock-timeout <seconds>',
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
@@ -49,6 +49,7 @@ program
.option(
'--no-lock',
'Disable the budget directory lock (use with care, env: ACTUAL_NO_LOCK)',
false,
)
.addOption(
new Option('--format <format>', 'Output format: json, table, csv')

View File

@@ -32,15 +32,9 @@ export function parseNonNegativeIntFlag(
return parsed;
}
export function parseBoolEnv(
raw: string | undefined,
source: string,
): boolean | undefined {
export function parseBoolEnv(raw: string | undefined): boolean | undefined {
if (raw === undefined) return undefined;
const lower = raw.toLowerCase();
if (raw === '1' || lower === 'true') return true;
if (raw === '0' || lower === 'false') return false;
throw new Error(
`Invalid ${source}: "${raw}". Expected "true", "false", "1", or "0".`,
);
if (raw === '1' || raw.toLowerCase() === 'true') return true;
if (raw === '0' || raw.toLowerCase() === 'false') return false;
return undefined;
}

View File

@@ -4,11 +4,8 @@ import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
// oxlint-disable-next-line actual/enforce-boundaries
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
// oxlint-disable-next-line actual/enforce-boundaries
import * as lightTheme from '../../desktop-client/src/style/themes/light';
// oxlint-disable-next-line actual/enforce-boundaries
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
const THEMES = {

View File

@@ -58,7 +58,7 @@
"@svgr/babel-plugin-add-jsx-attribute": "^8.0.0",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint-plugin-storybook": "^10.3.4",
"react": "19.2.4",

View File

@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$(dirname "$0")")"
@@ -8,10 +7,20 @@ if ! [ -x "$(command -v protoc)" ]; then
exit 1
fi
protoc --plugin="protoc-gen-es=../../node_modules/.bin/protoc-gen-es" \
--es_opt=target=ts \
--es_out="src/proto" \
export PATH="$PWD/bin:$PATH"
protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
--ts_opt=esModuleInterop=true \
--ts_out="src/proto" \
--js_out=import_style=commonjs,binary:src/proto \
--proto_path=src/proto \
sync.proto
../../node_modules/.bin/oxfmt src/proto/*.ts
../../node_modules/.bin/oxfmt src/proto/*.d.ts
for file in src/proto/*.d.ts; do
{ echo "/* oxlint-disable typescript/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
rm "$file"
done
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'

View File

@@ -4,17 +4,16 @@
"description": "CRDT layer of Actual",
"license": "MIT",
"files": [
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
"dist"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
@@ -22,25 +21,25 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
},
"scripts": {
"build:node": "vite build",
"proto:generate": "./bin/generate-proto",
"build": "yarn run build:node && tsgo -b",
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
"test": "vitest --run",
"typecheck": "tsgo -b"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"google-protobuf": "^3.21.4",
"murmurhash": "^2.0.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.11.0",
"@typescript/native-preview": "beta",
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"protoc-gen-js": "3.21.4-4",
"rollup-plugin-visualizer": "^7.0.1",
"ts-protoc-gen": "0.15.0",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}

View File

@@ -1,3 +1,6 @@
import './proto/sync_pb.js'; // Import for side effects
import type * as SyncPb from './proto/sync_pb';
export {
merkle,
getClock,
@@ -10,17 +13,16 @@ export {
Timestamp,
} from './crdt';
export {
type EncryptedData,
type Message,
type MessageEnvelope,
type SyncRequest,
type SyncResponse,
EncryptedDataSchema,
MessageSchema,
MessageEnvelopeSchema,
SyncRequestSchema,
SyncResponseSchema,
} from './proto/sync_pb';
declare global {
var proto: typeof SyncPb;
}
export { create, fromBinary, toBinary } from '@bufbuild/protobuf';
const { proto } = globalThis;
export const SyncRequest = proto.SyncRequest;
export const SyncResponse = proto.SyncResponse;
export const Message = proto.Message;
export const MessageEnvelope = proto.MessageEnvelope;
export const EncryptedData = proto.EncryptedData;
export const SyncProtoBuf = proto;

View File

@@ -21,7 +21,6 @@ message MessageEnvelope {
}
message SyncRequest {
reserved 4;
repeated MessageEnvelope messages = 1;
string fileId = 2;
string groupId = 3;

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +1,217 @@
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
// @generated from file sync.proto (syntax proto3)
/* eslint-disable */
/* oxlint-disable typescript/no-namespace */
// package:
// file: sync.proto
import type { Message as Message$1 } from '@bufbuild/protobuf';
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2';
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2';
import * as jspb from 'google-protobuf';
/**
* Describes the file sync.proto.
*/
export const file_sync: GenFile /*@__PURE__*/ = fileDesc(
'CgpzeW5jLnByb3RvIjoKDUVuY3J5cHRlZERhdGESCgoCaXYYASABKAwSDwoHYXV0aFRhZxgCIAEoDBIMCgRkYXRhGAMgASgMIkYKB01lc3NhZ2USDwoHZGF0YXNldBgBIAEoCRILCgNyb3cYAiABKAkSDgoGY29sdW1uGAMgASgJEg0KBXZhbHVlGAQgASgJIkoKD01lc3NhZ2VFbnZlbG9wZRIRCgl0aW1lc3RhbXAYASABKAkSEwoLaXNFbmNyeXB0ZWQYAiABKAgSDwoHY29udGVudBgDIAEoDCJ2CgtTeW5jUmVxdWVzdBIiCghtZXNzYWdlcxgBIAMoCzIQLk1lc3NhZ2VFbnZlbG9wZRIOCgZmaWxlSWQYAiABKAkSDwoHZ3JvdXBJZBgDIAEoCRINCgVrZXlJZBgFIAEoCRINCgVzaW5jZRgGIAEoCUoECAQQBSJCCgxTeW5jUmVzcG9uc2USIgoIbWVzc2FnZXMYASADKAsyEC5NZXNzYWdlRW52ZWxvcGUSDgoGbWVya2xlGAIgASgJYgZwcm90bzM',
);
export declare class EncryptedData extends jspb.Message {
getIv(): Uint8Array | string;
getIv_asU8(): Uint8Array;
getIv_asB64(): string;
setIv(value: Uint8Array | string): void;
/**
* @generated from message EncryptedData
*/
export type EncryptedData = Message$1<'EncryptedData'> & {
/**
* @generated from field: bytes iv = 1;
*/
iv: Uint8Array;
getAuthtag(): Uint8Array | string;
getAuthtag_asU8(): Uint8Array;
getAuthtag_asB64(): string;
setAuthtag(value: Uint8Array | string): void;
/**
* @generated from field: bytes authTag = 2;
*/
authTag: Uint8Array;
getData(): Uint8Array | string;
getData_asU8(): Uint8Array;
getData_asB64(): string;
setData(value: Uint8Array | string): void;
/**
* @generated from field: bytes data = 3;
*/
data: Uint8Array;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): EncryptedData.AsObject;
static toObject(
includeInstance: boolean,
msg: EncryptedData,
): EncryptedData.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: EncryptedData,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): EncryptedData;
static deserializeBinaryFromReader(
message: EncryptedData,
reader: jspb.BinaryReader,
): EncryptedData;
}
/**
* Describes the message EncryptedData.
* Use `create(EncryptedDataSchema)` to create a new message.
*/
export const EncryptedDataSchema: GenMessage<EncryptedData> /*@__PURE__*/ =
messageDesc(file_sync, 0);
export namespace EncryptedData {
export type AsObject = {
iv: Uint8Array | string;
authtag: Uint8Array | string;
data: Uint8Array | string;
};
}
/**
* @generated from message Message
*/
export type Message = Message$1<'Message'> & {
/**
* @generated from field: string dataset = 1;
*/
dataset: string;
export declare class Message extends jspb.Message {
getDataset(): string;
setDataset(value: string): void;
/**
* @generated from field: string row = 2;
*/
row: string;
getRow(): string;
setRow(value: string): void;
/**
* @generated from field: string column = 3;
*/
column: string;
getColumn(): string;
setColumn(value: string): void;
/**
* @generated from field: string value = 4;
*/
value: string;
};
getValue(): string;
setValue(value: string): void;
/**
* Describes the message Message.
* Use `create(MessageSchema)` to create a new message.
*/
export const MessageSchema: GenMessage<Message> /*@__PURE__*/ = messageDesc(
file_sync,
1,
);
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Message.AsObject;
static toObject(includeInstance: boolean, msg: Message): Message.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: Message,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): Message;
static deserializeBinaryFromReader(
message: Message,
reader: jspb.BinaryReader,
): Message;
}
/**
* @generated from message MessageEnvelope
*/
export type MessageEnvelope = Message$1<'MessageEnvelope'> & {
/**
* @generated from field: string timestamp = 1;
*/
timestamp: string;
export namespace Message {
export type AsObject = {
dataset: string;
row: string;
column: string;
value: string;
};
}
/**
* @generated from field: bool isEncrypted = 2;
*/
isEncrypted: boolean;
export declare class MessageEnvelope extends jspb.Message {
getTimestamp(): string;
setTimestamp(value: string): void;
/**
* @generated from field: bytes content = 3;
*/
content: Uint8Array;
};
getIsencrypted(): boolean;
setIsencrypted(value: boolean): void;
/**
* Describes the message MessageEnvelope.
* Use `create(MessageEnvelopeSchema)` to create a new message.
*/
export const MessageEnvelopeSchema: GenMessage<MessageEnvelope> /*@__PURE__*/ =
messageDesc(file_sync, 2);
getContent(): Uint8Array | string;
getContent_asU8(): Uint8Array;
getContent_asB64(): string;
setContent(value: Uint8Array | string): void;
/**
* @generated from message SyncRequest
*/
export type SyncRequest = Message$1<'SyncRequest'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): MessageEnvelope.AsObject;
static toObject(
includeInstance: boolean,
msg: MessageEnvelope,
): MessageEnvelope.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: MessageEnvelope,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): MessageEnvelope;
static deserializeBinaryFromReader(
message: MessageEnvelope,
reader: jspb.BinaryReader,
): MessageEnvelope;
}
/**
* @generated from field: string fileId = 2;
*/
fileId: string;
export namespace MessageEnvelope {
export type AsObject = {
timestamp: string;
isencrypted: boolean;
content: Uint8Array | string;
};
}
/**
* @generated from field: string groupId = 3;
*/
groupId: string;
export declare class SyncRequest extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
/**
* @generated from field: string keyId = 5;
*/
keyId: string;
getFileid(): string;
setFileid(value: string): void;
/**
* @generated from field: string since = 6;
*/
since: string;
};
getGroupid(): string;
setGroupid(value: string): void;
/**
* Describes the message SyncRequest.
* Use `create(SyncRequestSchema)` to create a new message.
*/
export const SyncRequestSchema: GenMessage<SyncRequest> /*@__PURE__*/ =
messageDesc(file_sync, 3);
getKeyid(): string;
setKeyid(value: string): void;
/**
* @generated from message SyncResponse
*/
export type SyncResponse = Message$1<'SyncResponse'> & {
/**
* @generated from field: repeated MessageEnvelope messages = 1;
*/
messages: MessageEnvelope[];
getSince(): string;
setSince(value: string): void;
/**
* @generated from field: string merkle = 2;
*/
merkle: string;
};
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncRequest.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncRequest,
): SyncRequest.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncRequest,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncRequest;
static deserializeBinaryFromReader(
message: SyncRequest,
reader: jspb.BinaryReader,
): SyncRequest;
}
/**
* Describes the message SyncResponse.
* Use `create(SyncResponseSchema)` to create a new message.
*/
export const SyncResponseSchema: GenMessage<SyncResponse> /*@__PURE__*/ =
messageDesc(file_sync, 4);
export namespace SyncRequest {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
fileid: string;
groupid: string;
keyid: string;
since: string;
};
}
export declare class SyncResponse extends jspb.Message {
clearMessagesList(): void;
getMessagesList(): Array<MessageEnvelope>;
setMessagesList(value: Array<MessageEnvelope>): void;
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
getMerkle(): string;
setMerkle(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): SyncResponse.AsObject;
static toObject(
includeInstance: boolean,
msg: SyncResponse,
): SyncResponse.AsObject;
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
static extensionsBinary: {
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
};
static serializeBinaryToWriter(
message: SyncResponse,
writer: jspb.BinaryWriter,
): void;
static deserializeBinary(bytes: Uint8Array): SyncResponse;
static deserializeBinaryFromReader(
message: SyncResponse,
reader: jspb.BinaryReader,
): SyncResponse;
}
export namespace SyncResponse {
export type AsObject = {
messagesList: Array<MessageEnvelope.AsObject>;
merkle: string;
};
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false
},
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: true,
external: ['@bufbuild/protobuf', 'murmurhash'],
external: ['google-protobuf', 'murmurhash'],
},
build: {
ssr: true,
@@ -16,7 +16,7 @@ export default defineConfig({
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
formats: ['es'],
formats: ['cjs'],
fileName: () => 'index.js',
},
},

View File

@@ -8,7 +8,6 @@ coverage
test-results
playwright-report
blob-report
.playwright-cli
# production
build

View File

@@ -0,0 +1,17 @@
#!/bin/sh -ex
ROOT=`dirname $0`
cd "$ROOT/.."
echo "Building the browser..."
rm -fr build
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
yarn build --mode=browser
rm -fr build-stats
mkdir build-stats
mv build/kcab/stats.json build-stats/loot-core-stats.json
mv ./stats.json build-stats/web-stats.json

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env node
// Minimal static file server for the prebuilt browser bundle at
// packages/desktop-client/build. Serves with the COOP/COEP headers required
// by the app (SharedArrayBuffer/SQLite). Intended for CI e2e runs where
// starting the full Vite dev server is unnecessary overhead.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', 'build');
const INDEX_PATH = path.join(ROOT, 'index.html');
const PORT = Number(process.env.PORT) || 3001;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.wasm': 'application/wasm',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain; charset=utf-8',
};
function setSharedHeaders(res) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
}
function resolveFile(urlPath) {
let cleanPath;
try {
cleanPath = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
} catch {
return null;
}
if (cleanPath.includes('\0')) return null;
// Strip leading slashes so path.resolve treats it as relative to ROOT,
// regardless of whether the URL was absolute or contained duplicate
// separators.
const relPath = cleanPath.replace(/^\/+/, '');
const candidate = path.resolve(ROOT, relPath);
const relative = path.relative(ROOT, candidate);
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
try {
return fs.statSync(candidate).isFile() ? candidate : null;
} catch {
return null;
}
}
const server = http.createServer((req, res) => {
setSharedHeaders(res);
const rawUrlPath = (req.url || '/').split('?')[0].split('#')[0];
let filePath = resolveFile(req.url || '/');
// SPA fallback: serve index.html only for routes without a file extension
// (i.e. client-side routes). Asset requests that miss get a real 404 so the
// browser doesn't receive HTML when it asked for JS/CSS/etc.
if (!filePath) {
const hasExtension = path.extname(rawUrlPath) !== '';
if (hasExtension) {
res.writeHead(404);
res.end('Not found');
return;
}
filePath = INDEX_PATH;
}
const ext = path.extname(filePath).toLowerCase();
res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
fs.createReadStream(filePath)
.on('error', err => {
res.writeHead(500);
res.end(String(err));
})
.pipe(res);
});
server.listen(PORT, () => {
console.log(`serve-build: serving ${ROOT} on http://localhost:${PORT}`);
});

View File

@@ -1,210 +0,0 @@
import { appendFileSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { CatalogTheme } from '../src/style/customThemes.ts';
import {
embedThemeFonts,
validateThemeCss,
} from '../src/style/customThemes.ts';
const MAX_CSS_BYTES = 512 * 1024;
const FETCH_TIMEOUT_MS = 15_000;
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
type ThemeResult = {
name: string;
repo: string;
status: 'ok' | 'error';
error?: string;
};
const here = dirname(fileURLToPath(import.meta.url));
const catalogPath = resolve(
here,
'..',
'src',
'data',
'customThemeCatalog.json',
);
function readCatalog(): CatalogTheme[] {
const raw = readFileSync(catalogPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Catalog JSON must be an array.');
}
return parsed.map((entry, i) => validateCatalogEntry(entry, i));
}
function validateCatalogEntry(value: unknown, index: number): CatalogTheme {
if (!value || typeof value !== 'object') {
throw new Error(`Catalog entry #${index} is not an object.`);
}
const e = value as Record<string, unknown>;
if (typeof e.name !== 'string' || !e.name.trim()) {
throw new Error(`Catalog entry #${index} is missing a valid "name".`);
}
// Schema-check the repo before it gets interpolated into a fetch URL.
if (typeof e.repo !== 'string' || !REPO_PATTERN.test(e.repo)) {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "repo" (expected "owner/repo"): ${JSON.stringify(e.repo)}`,
);
}
if (e.mode !== 'light' && e.mode !== 'dark') {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "mode" (expected "light" or "dark").`,
);
}
if (
e.colors !== undefined &&
(!Array.isArray(e.colors) ||
!e.colors.every((c: unknown) => typeof c === 'string'))
) {
throw new Error(
`Catalog entry "${String(e.name)}" has an invalid "colors" (expected string[]).`,
);
}
return {
name: e.name,
repo: e.repo,
mode: e.mode,
colors: e.colors as string[] | undefined,
};
}
async function fetchCss(url: string): Promise<string> {
const response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
redirect: 'error',
headers: { Accept: 'text/css, text/plain, */*' },
});
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
);
}
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const size = Number.parseInt(contentLength, 10);
if (Number.isFinite(size) && size > MAX_CSS_BYTES) {
throw new Error(
`CSS at ${url} is ${size} bytes; max allowed is ${MAX_CSS_BYTES} bytes.`,
);
}
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error(`Response from ${url} has no body.`);
}
const decoder = new TextDecoder('utf-8');
let received = 0;
let text = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAX_CSS_BYTES) {
await reader.cancel();
throw new Error(
`CSS at ${url} exceeds max allowed size of ${MAX_CSS_BYTES} bytes.`,
);
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
}
async function validateOne(entry: CatalogTheme): Promise<ThemeResult> {
try {
const url = `https://raw.githubusercontent.com/${entry.repo}/refs/heads/main/actual.css`;
const css = await fetchCss(url);
// Embed fonts before validation: the validator only accepts data: URIs in
// @font-face, and embedThemeFonts is what turns relative url() refs into
// data: URIs. Matches ThemeInstaller's install flow.
const embedded = await embedThemeFonts(css, entry.repo);
validateThemeCss(embedded);
return { name: entry.name, repo: entry.repo, status: 'ok' };
} catch (err) {
return {
name: entry.name,
repo: entry.repo,
status: 'error',
error: err instanceof Error ? err.message : String(err),
};
}
}
function escapeForMarkdown(s: string): string {
return s.replace(/[`<>|]/g, c => `\\${c}`).replace(/\r?\n/g, ' ');
}
function writeStepSummary(results: ThemeResult[]): void {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) return;
const okCount = results.filter(r => r.status === 'ok').length;
const failCount = results.length - okCount;
const lines: string[] = [];
lines.push('# Custom theme catalog scan');
lines.push('');
lines.push(`- Total themes: ${results.length}`);
lines.push(`- Passing: ${okCount}`);
lines.push(`- Failing: ${failCount}`);
lines.push('');
lines.push('| Status | Theme | Repo | Error |');
lines.push('| --- | --- | --- | --- |');
for (const r of results) {
const status = r.status === 'ok' ? 'pass' : 'FAIL';
const err = r.error ? escapeForMarkdown(r.error) : '';
lines.push(
`| ${status} | ${escapeForMarkdown(r.name)} | ${escapeForMarkdown(r.repo)} | ${err} |`,
);
}
lines.push('');
appendFileSync(summaryPath, lines.join('\n') + '\n');
}
async function main(): Promise<void> {
const catalog = readCatalog();
console.log(`Validating ${catalog.length} theme(s) from the catalog…`);
const results: ThemeResult[] = [];
for (const entry of catalog) {
const result = await validateOne(entry);
if (result.status === 'ok') {
console.log(` ok ${entry.repo.padEnd(55)} ${entry.name}`);
} else {
console.log(
` FAIL ${entry.repo.padEnd(55)} ${entry.name}\n → ${result.error}`,
);
}
results.push(result);
}
const failed = results.filter(r => r.status === 'error');
console.log('');
console.log(
`Summary: ${results.length - failed.length}/${results.length} passing, ${failed.length} failing.`,
);
writeStepSummary(results);
process.exit(failed.length === 0 ? 0 : 1);
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -86,14 +86,7 @@ test.describe('Accounts', () => {
credit: '34.56',
});
// Wait for both newly created transactions to actually be in the
// transaction list before selecting them. A bare waitForTimeout(100)
// here is not enough under parallel CI load: the second
// createSingleTransaction's row may still be mounting when the
// selection clicks land, so the selection doesn't stick and the
// 'Make transfer' button (rendered only when items are selected)
// never appears.
await expect(accountPage.getNthTransaction(1).payee).toBeVisible();
await page.waitForTimeout(100); // Give time for the previous transaction to be rendered
await accountPage.selectNthTransaction(0);
await accountPage.selectNthTransaction(1);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,57 +1,10 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import type { Browser, Locator, Page } from '@playwright/test';
import { expect as baseExpect } from '@playwright/test';
import type { Locator } from '@playwright/test';
/**
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
* The Modal component's 100ms opacity transition races with Playwright's
* click-stability check under parallel CI load ("element was detached
* from the DOM, retrying"); snapping to final state makes clicks
* deterministic.
*
* Wraps `browser.newPage` on the worker-scoped browser fixture because
* every test creates its own page via `browser.newPage()` rather than
* using the test-scoped `page` fixture — a `page`-fixture override would
* be a no-op.
*/
const disableAnimationsInitScript = () => {
const css = `*, *::before, *::after {
transition-duration: 0s !important;
transition-delay: 0s !important;
animation-duration: 0s !important;
animation-delay: 0s !important;
}`;
const install = () => {
const style = document.createElement('style');
style.setAttribute('data-e2e-disable-animations', 'true');
style.textContent = css;
document.head.appendChild(style);
};
if (document.head) {
install();
} else {
document.addEventListener('DOMContentLoaded', install);
}
};
export const test = process.env.VRT
? base
: base.extend<object, { browser: Browser }>({
browser: [
async ({ browser }, runWithBrowser) => {
const originalNewPage = browser.newPage.bind(browser);
browser.newPage = async options => {
const page = await originalNewPage(options);
await page.addInitScript(disableAnimationsInitScript);
return page;
};
await runWithBrowser(browser);
},
{ scope: 'worker' },
],
});
export { test } from '@playwright/test';
export const expect = baseExpect.extend({
async toMatchThemeScreenshots(target: Locator | Page) {
async toMatchThemeScreenshots(locator: Locator) {
// Disable screenshot assertions in regular e2e tests;
// only enable them when doing VRT tests
if (!process.env.VRT) {
@@ -62,33 +15,38 @@ export const expect = baseExpect.extend({
}
const config = {
mask: [target.locator('[data-vrt-mask="true"]')],
mask: [locator.locator('[data-vrt-mask="true"]')],
maxDiffPixels: 5,
};
const page: Page = 'page' in target ? target.page() : target;
const dataThemeLocator = page.locator('[data-theme]');
// Get the data-theme attribute from page.
// If there is a page() function, it means that the locator
// is not a page object but a locator object.
const dataThemeLocator =
typeof locator.page === 'function'
? locator.page().locator('[data-theme]')
: locator.locator('[data-theme]');
// Check lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to darkmode and check
await page.evaluate(() => window.Actual.setTheme('dark'));
await locator.evaluate(() => window.Actual.setTheme('dark'));
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch to midnight theme and check
await page.evaluate(() => window.Actual.setTheme('midnight'));
await locator.evaluate(() => window.Actual.setTheme('midnight'));
await baseExpect(dataThemeLocator).toHaveAttribute(
'data-theme',
'midnight',
);
await baseExpect(target).toHaveScreenshot(config);
await baseExpect(locator).toHaveScreenshot(config);
// Switch back to lightmode
await page.evaluate(() => window.Actual.setTheme('auto'));
await locator.evaluate(() => window.Actual.setTheme('auto'));
return {
message: () => 'pass',
pass: true,

View File

@@ -16,15 +16,6 @@ export class BudgetPage {
this.budgetTableTotals = this.budgetTable.getByTestId('budget-totals');
}
/**
* Wait for the budget page to finish loading. The budget-table is
* inside AutoSizer which returns null until layout provides width/
* height, so it only appears after the page has fully mounted.
*/
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.budgetTable.waitFor(...options);
}
async getTotalBudgeted() {
const totalBudgetedText = await this.budgetTableTotals
.getByTestId(/total-budgeted$/)

View File

@@ -16,18 +16,12 @@ export class ConfigurationPage {
async createTestFile() {
await this.page.getByRole('button', { name: 'Create test file' }).click();
const budgetPage = new BudgetPage(this.page);
// Wait for the budget page to be fully mounted before returning so
// callers don't race the virtualized budget-table's layout step.
await budgetPage.waitFor();
return budgetPage;
return new BudgetPage(this.page);
}
async createDemoFile() {
await this.page.getByRole('button', { name: 'View demo' }).click();
const budgetPage = new BudgetPage(this.page);
await budgetPage.waitFor();
return budgetPage;
return new BudgetPage(this.page);
}
async clickOnNoServer() {
@@ -43,9 +37,7 @@ export class ConfigurationPage {
async startFresh() {
await this.page.getByRole('button', { name: 'Start fresh' }).click();
const accountPage = new AccountPage(this.page);
await accountPage.accountName.waitFor();
return accountPage;
return new AccountPage(this.page);
}
async importBudget(type: 'YNAB4' | 'nYNAB' | 'Actual', file: string) {

View File

@@ -312,14 +312,21 @@ export class MobileBudgetPage {
async #getButtonForEnvelopeBudgetSummary({
throwIfNotFound = true,
}: { throwIfNotFound?: boolean } = {}) {
const button = this.toBudgetButton.or(this.overbudgetedButton).first();
try {
await button.waitFor();
} catch (err) {
if (!throwIfNotFound) return null;
throw err;
if (await this.toBudgetButton.isVisible()) {
return this.toBudgetButton;
}
return button;
if (await this.overbudgetedButton.isVisible()) {
return this.overbudgetedButton;
}
if (!throwIfNotFound) {
return null;
}
throw new Error(
'Neither "To Budget" nor "Overbudgeted" button could be located on the page.',
);
}
async openEnvelopeBudgetSummary() {
@@ -339,17 +346,25 @@ export class MobileBudgetPage {
async #getButtonForTrackingBudgetSummary({
throwIfNotFound = true,
}: { throwIfNotFound?: boolean } = {}) {
const button = this.savedButton
.or(this.projectedSavingsButton)
.or(this.overspentButton)
.first();
try {
await button.waitFor();
} catch (err) {
if (!throwIfNotFound) return null;
throw err;
if (await this.savedButton.isVisible()) {
return this.savedButton;
}
return button;
if (await this.projectedSavingsButton.isVisible()) {
return this.projectedSavingsButton;
}
if (await this.overspentButton.isVisible()) {
return this.overspentButton;
}
if (!throwIfNotFound) {
return null;
}
throw new Error(
'None of "Saved", "Projected savings", or "Overspent" buttons could be located on the page.',
);
}
async openTrackingBudgetSummary() {

View File

@@ -131,9 +131,7 @@ export class MobileNavigation {
}
const link = this.navbar.getByRole('link', { name: pageName });
// Click via evaluate: the navbar uses react-spring transforms, so
// Playwright's viewport-stability check rejects mid-animation clicks.
await link.evaluate(el => (el as HTMLElement).click());
await link.click();
await pageInstance.waitFor();

View File

@@ -25,31 +25,10 @@ export class MobileTransactionEntryPage {
await this.transactionForm.waitFor(...options);
}
async fillAmount(value: string) {
await this.amountField.fill(value);
await this.amountField.evaluate(el => (el as HTMLInputElement).blur());
// TransactionEdit.onUpdate runs an async rules-run before setTransactions,
// so wait for the outer display button (reads props.value) to reflect the
// committed amount before the next fillField snapshots the transaction.
await this.transactionForm
.getByRole('button')
.filter({ hasText: value })
.waitFor();
}
async fillField(fieldLocator: Locator, content: string) {
await fieldLocator.click();
const comboboxInput = this.page.getByRole('combobox').locator('input');
// pressSequentially + option click: fill()+Enter breaks the autocomplete
// highlight and selects "None"/wrong entry under CPU contention.
await comboboxInput.pressSequentially(content);
await this.page
.getByRole('option')
.filter({ hasText: content })
.first()
.click();
await comboboxInput.waitFor({ state: 'hidden' });
await fieldLocator.filter({ hasText: content }).waitFor();
await this.page.locator('css=[role=combobox] input').fill(content);
await this.page.keyboard.press('Enter');
}
async createTransaction() {

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { AccountPage } from './account-page';
import { BankSyncPage } from './bank-sync-page';
@@ -14,55 +14,6 @@ type AccountEntry = {
offBudget: boolean;
};
/**
* Click a React Aria <Button> via a programmatic browser-side click.
*
* React Aria components re-render on focus state changes (`data-focused`,
* `data-focus-visible`). Under parallel CI load, Playwright's `.click()`
* stability check often sees the DOM node get detached and re-mounted
* mid-action, leading to "element was detached from the DOM, retrying"
* loops that exhaust the test timeout.
*
* `dispatchEvent('click')` is documented to skip stability checks but
* dispatches a generic `Event` that React Aria's pointer-based onPress
* does not respond to, so it doesn't actually activate the button.
*
* Calling `.click()` inside `page.evaluate` runs synchronously in the
* browser: querySelector + click happen in one JS task with no CDP
* roundtrip in between, so React has no chance to re-render between
* resolution and click. HTMLElement.click() dispatches a real MouseEvent
* that React Aria handles correctly.
*/
async function clickReactAriaButton(locator: Locator): Promise<void> {
await locator.evaluate((el: HTMLElement) => el.click());
}
/**
* Fill a React-controlled input via the native value setter + input event.
*
* React's controlled inputs respond to `input` events to update state.
* Playwright's `.fill()` clears + types, dispatching multiple events that
* each cause React Aria's input wrappers to re-render. Under load this
* loops indefinitely as `.fill()`'s editability check keeps seeing
* detached nodes.
*
* The native value setter pattern (https://stackoverflow.com/q/23892547)
* sets the value once and dispatches a single input event. React
* processes one state update and one re-render, then the input is stable.
*/
async function fillReactInput(locator: Locator, value: string): Promise<void> {
await locator.evaluate((el, val) => {
const input = el as HTMLInputElement;
// oxlint-disable-next-line typescript/unbound-method -- documented React-controlled-input pattern
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
setter?.call(input, val);
input.dispatchEvent(new Event('input', { bubbles: true }));
}, value);
}
export class Navigation {
readonly page: Page;
@@ -144,34 +95,22 @@ export class Navigation {
async createAccount(data: AccountEntry) {
await this.page.getByRole('button', { name: 'Add account' }).click();
// Clicking "Create a local account" pushes a second modal whose
// heading is "Create Local Account". Wait for that heading to
// confirm the form is fully mounted before touching any fields.
await clickReactAriaButton(
this.page.getByRole('button', { name: 'Create a local account' }),
);
await this.page
.getByRole('heading', { name: 'Create Local Account' })
.waitFor({ state: 'visible' });
.getByRole('button', { name: 'Create a local account' })
.click();
await fillReactInput(this.page.getByLabel('Name'), data.name);
await fillReactInput(this.page.getByLabel('Balance'), String(data.balance));
// Fill the form
await this.page.getByLabel('Name:').fill(data.name);
await this.page.getByLabel('Balance:').fill(String(data.balance));
if (data.offBudget) {
await this.page.getByLabel('Off budget').click();
}
await clickReactAriaButton(
this.page.getByRole('button', { name: 'Create', exact: true }),
);
const accountPage = new AccountPage(this.page);
await accountPage.waitFor();
if (data.balance !== 0) {
await accountPage.transactionTableRow.first().waitFor();
}
return accountPage;
await this.page
.getByRole('button', { name: 'Create', exact: true })
.click();
return new AccountPage(this.page);
}
async clickOnNoServer() {

View File

@@ -46,11 +46,13 @@ export class ScheduleEditModal {
}
if (data.payee) {
await this.#typeAndSelectOption(this.payeeInput, data.payee);
await this.payeeInput.pressSequentially(data.payee);
await this.page.keyboard.press('Enter');
}
if (data.account) {
await this.#typeAndSelectOption(this.accountInput, data.account);
await this.accountInput.pressSequentially(data.account);
await this.page.keyboard.press('Enter');
}
if (data.amount) {
@@ -58,16 +60,6 @@ export class ScheduleEditModal {
}
}
async #typeAndSelectOption(input: Locator, content: string) {
await input.pressSequentially(content);
// Click the option: Enter on a not-yet-highlighted list saves "None".
await this.page
.getByRole('option')
.filter({ hasText: content })
.first()
.click();
}
async save() {
await this.saveButton.click();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -32,7 +32,7 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.fillAmount('12.34');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -87,7 +87,7 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await expect(page).toMatchThemeScreenshots();
await transactionEntryPage.fillAmount('12.34');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -109,7 +109,7 @@ test.describe('Mobile Transactions', () => {
test('creates an uncategorized transaction from `/categories/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.fillAmount('12.35');
await transactionEntryPage.amountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -123,7 +123,7 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.fillAmount('12.34');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -142,7 +142,7 @@ test.describe('Mobile Transactions', () => {
test('creates a categorized transaction from `/categories/uncategorized` page', async () => {
// Create uncategorized transaction
let transactionEntryPage = await navigation.goToTransactionEntryPage();
await transactionEntryPage.fillAmount('12.35');
await transactionEntryPage.amountField.fill('12.35');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(
@@ -156,7 +156,7 @@ test.describe('Mobile Transactions', () => {
await expect(transactionEntryPage.header).toHaveText('New Transaction');
await transactionEntryPage.fillAmount('12.34');
await transactionEntryPage.amountField.fill('12.34');
// Click anywhere to cancel active edit.
await transactionEntryPage.header.click();
await transactionEntryPage.fillField(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,9 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"types": ["@playwright/test", "node"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../../loot-core/typings/window.ts"]
}

View File

@@ -86,10 +86,7 @@
'Arial',
sans-serif
);
font-feature-settings:
'ss01',
'ss04',
'calt' 0;
font-feature-settings: 'ss01', 'ss04';
}
html,

View File

@@ -1,12 +1,7 @@
{
"name": "@actual-app/web",
"version": "26.5.0",
"version": "26.4.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/actualbudget/actual.git",
"directory": "packages/desktop-client"
},
"files": [
"build"
],
@@ -30,20 +25,15 @@
"#gocardless": "./src/gocardless.ts",
"#i18n": "./src/i18n.ts",
"#mocks": "./src/mocks.tsx",
"#mocks/*": "./src/mocks/*.ts",
"#polyfills": "./src/polyfills.ts",
"#components/forms": "./src/components/forms/index.tsx",
"#components/banksync": "./src/components/banksync/index.tsx",
"#components/banksync/useBankSyncAccountSettings": "./src/components/banksync/useBankSyncAccountSettings.ts",
"#components/budget": "./src/components/budget/index.tsx",
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
"#components/budget/goals/reducer": "./src/components/budget/goals/reducer.ts",
"#components/budget/goals/useBudgetAutomationCategories": "./src/components/budget/goals/useBudgetAutomationCategories.ts",
"#components/budget/goals/validateAutomation": "./src/components/budget/goals/validateAutomation.ts",
"#components/budget/util": "./src/components/budget/util.ts",
"#components/codemirror/autocompleteTabAccept": "./src/components/codemirror/autocompleteTabAccept.ts",
"#components/mobile/utils": "./src/components/mobile/utils.ts",
@@ -108,10 +98,9 @@
"start:browser": "cross-env ./bin/watch-browser",
"watch": "cross-env BROWSER=none yarn start",
"build": "vite build",
"build:browser": "vite build --mode=browser",
"build:browser": "cross-env ./bin/build-browser",
"generate:i18n": "i18next",
"test": "vitest --run",
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
"e2e": "npx playwright test --browser=chromium",
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
"playwright": "playwright",
@@ -145,7 +134,7 @@
"@types/promise-retry": "^1.1.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "beta",
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
"@uiw/react-codemirror": "^4.25.9",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.3.0",
@@ -168,7 +157,6 @@
"mdast-util-newline-to-break": "^2.0.0",
"memoize-one": "^6.0.0",
"pikaday": "1.8.2",
"plugins-service": "workspace:*",
"promise-retry": "^2.0.1",
"re-resizable": "^6.11.2",
"react": "19.2.4",

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