Compare commits
56 Commits
worktree-c
...
cursor/res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31967e36a4 | ||
|
|
4a5ee9c2dc | ||
|
|
a8eb204ce7 | ||
|
|
f68e4fbb2a | ||
|
|
dd3b1144d1 | ||
|
|
ff0f5bdb35 | ||
|
|
11ce29e7fd | ||
|
|
d58c9a9a07 | ||
|
|
598e3ec9d8 | ||
|
|
c2987af64f | ||
|
|
c7d39961cf | ||
|
|
a42b7c5777 | ||
|
|
33af9bf906 | ||
|
|
46687da7a8 | ||
|
|
3928d5b2a8 | ||
|
|
8b29ee40a7 | ||
|
|
9acbd6388b | ||
|
|
77f0a3e58b | ||
|
|
eb922fd191 | ||
|
|
2b584e1ad0 | ||
|
|
4f1bc3fcdd | ||
|
|
ea50db524b | ||
|
|
da1d0a94b9 | ||
|
|
daab7f737e | ||
|
|
686f10247d | ||
|
|
227c995155 | ||
|
|
c8224d24be | ||
|
|
29a06b23ea | ||
|
|
aeb28d3b87 | ||
|
|
bd1da27404 | ||
|
|
7501674613 | ||
|
|
c3717e7036 | ||
|
|
9d91da77ec | ||
|
|
1c97388654 | ||
|
|
8323a7d27c | ||
|
|
7d4e28041c | ||
|
|
3c77b3d0d5 | ||
|
|
846b6a6b7a | ||
|
|
07c71154c9 | ||
|
|
2f49c5c400 | ||
|
|
4b28a8146e | ||
|
|
362d8d60e4 | ||
|
|
664cfdf244 | ||
|
|
880bb67423 | ||
|
|
3e35d3b6f5 | ||
|
|
75da8f1851 | ||
|
|
29275a573d | ||
|
|
ead1b8e39d | ||
|
|
3c361fdabf | ||
|
|
8691766fb8 | ||
|
|
e896ce408a | ||
|
|
3373154b40 | ||
|
|
c2150e5888 | ||
|
|
6b0242fa49 | ||
|
|
d8eba18a72 | ||
|
|
c91eea5439 |
@@ -1,8 +1,6 @@
|
||||
node_modules
|
||||
user-files
|
||||
server-files
|
||||
|
||||
# Yarn
|
||||
**/node_modules
|
||||
.git
|
||||
.lage
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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://github.com/actualbudget/docs#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://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
|
||||
|
||||
## Description
|
||||
|
||||
|
||||
4
.github/actions/docs-spelling/expect.txt
vendored
@@ -61,6 +61,7 @@ Dockerfiles
|
||||
Dominguez
|
||||
DUSSDEDDXXX
|
||||
DUSSELDORF
|
||||
ecf
|
||||
EDATE
|
||||
ENTERCARD
|
||||
Entra
|
||||
@@ -140,8 +141,6 @@ pluggyai
|
||||
Poste
|
||||
PPABPLPK
|
||||
prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
Qatari
|
||||
QNTOFRP
|
||||
@@ -172,7 +171,6 @@ SWEDBANK
|
||||
SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
|
||||
@@ -9,7 +9,7 @@ runs:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn workspaces focus @actual-app/ci-actions
|
||||
run: yarn workspaces focus actual @actual-app/ci-actions
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
5
.github/actions/setup/action.yml
vendored
@@ -52,8 +52,9 @@ runs:
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
persist-credentials: false
|
||||
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
|
||||
- name: Remove untranslated languages
|
||||
run: packages/desktop-client/bin/remove-untranslated-languages
|
||||
shell: bash
|
||||
if: ${{ inputs.download-translations == 'true' }}
|
||||
if: ${{ inputs.download-translations == 'true' && !env.ACT }}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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:
|
||||
|
||||
2
.github/workflows/autofix.yml
vendored
@@ -16,6 +16,8 @@ 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:
|
||||
|
||||
28
.github/workflows/build.yml
vendored
@@ -19,10 +19,26 @@ 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:
|
||||
@@ -45,9 +61,12 @@ 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:
|
||||
@@ -70,9 +89,12 @@ 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
|
||||
@@ -89,9 +111,12 @@ 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:
|
||||
@@ -114,9 +139,12 @@ 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:
|
||||
|
||||
32
.github/workflows/check.yml
vendored
@@ -12,20 +12,40 @@ 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:
|
||||
@@ -33,9 +53,12 @@ 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:
|
||||
@@ -43,9 +66,12 @@ 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:
|
||||
@@ -55,9 +81,12 @@ 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:
|
||||
@@ -75,10 +104,13 @@ 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:
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
@@ -23,6 +23,8 @@ 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
|
||||
|
||||
2
.github/workflows/count-points.yml
vendored
@@ -17,6 +17,8 @@ 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:
|
||||
|
||||
1
.github/workflows/cut-release-branch.yml
vendored
@@ -31,6 +31,7 @@ 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
|
||||
|
||||
2
.github/workflows/docker-edge.yml
vendored
@@ -37,6 +37,8 @@ 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
|
||||
|
||||
2
.github/workflows/docker-release.yml
vendored
@@ -29,6 +29,8 @@ 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
|
||||
|
||||
90
.github/workflows/e2e-test.yml
vendored
@@ -17,32 +17,80 @@ 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:
|
||||
functional:
|
||||
name: Functional (shard ${{ matrix.shard }}/5)
|
||||
build-web:
|
||||
name: Build web bundle
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
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)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-web
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
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 }}/5
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/3
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
if: failure()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
path: packages/desktop-client/test-results/
|
||||
@@ -56,6 +104,8 @@ 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:
|
||||
@@ -80,22 +130,34 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
vrt:
|
||||
name: Visual regression (shard ${{ matrix.shard }}/5)
|
||||
name: Visual regression (shard ${{ matrix.shard }}/3)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-web
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
shard: [1, 2, 3]
|
||||
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 }}/5
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/3
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
@@ -113,6 +175,8 @@ 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
|
||||
@@ -137,7 +201,9 @@ 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
|
||||
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 }}
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
6
.github/workflows/electron-master.yml
vendored
@@ -31,6 +31,8 @@ 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') }}
|
||||
@@ -57,9 +59,11 @@ 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
|
||||
|
||||
2
.github/workflows/electron-pr.yml
vendored
@@ -35,6 +35,8 @@ 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') }}
|
||||
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: actual
|
||||
persist-credentials: false
|
||||
- name: Set up environment
|
||||
uses: ./actual/.github/actions/setup
|
||||
with:
|
||||
@@ -59,6 +60,8 @@ 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: |
|
||||
|
||||
@@ -25,6 +25,8 @@ 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
|
||||
|
||||
2
.github/workflows/netlify-release.yml
vendored
@@ -22,6 +22,8 @@ jobs:
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
47
.github/workflows/nightly-theme-catalog-scan.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
16
.github/workflows/publish-flathub.yml
vendored
@@ -54,8 +54,9 @@ 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')
|
||||
@@ -77,7 +78,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..."
|
||||
@@ -90,27 +91,32 @@ 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: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${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: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${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
|
||||
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
|
||||
|
||||
124
.github/workflows/publish-nightly-npm-packages.yml
vendored
@@ -1,124 +0,0 @@
|
||||
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 }}
|
||||
60
.github/workflows/publish-npm-packages.yml
vendored
@@ -1,26 +1,55 @@
|
||||
name: Publish npm packages
|
||||
|
||||
# # Npm packages are published for every new tag
|
||||
# Npm packages are published for every new tag and nightly schedule
|
||||
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 Web
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
@@ -44,6 +73,7 @@ jobs:
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !env.ACT }}
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -60,6 +90,9 @@ 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
|
||||
@@ -69,35 +102,26 @@ jobs:
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npm publish loot-core/@actual-app/core.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npm publish api/@actual-app/api.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npm publish cli/@actual-app/cli.tgz --access public ${NPM_DIST_TAG:+--tag "$NPM_DIST_TAG"}
|
||||
|
||||
6
.github/workflows/release-notes.yml
vendored
@@ -37,13 +37,15 @@ 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
|
||||
|
||||
3
.github/workflows/size-compare.yml
vendored
@@ -38,6 +38,7 @@ 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:
|
||||
@@ -104,7 +105,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}}
|
||||
|
||||
7
.github/workflows/stale.yml
vendored
@@ -3,9 +3,12 @@ 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
|
||||
@@ -16,6 +19,8 @@ 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
|
||||
@@ -27,6 +32,8 @@ jobs:
|
||||
days-before-issue-stale: -1
|
||||
|
||||
stale-needs-info:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
|
||||
4
.github/workflows/vrt-update-apply.yml
vendored
@@ -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,6 +116,8 @@ 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'
|
||||
|
||||
8
.github/workflows/vrt-update-generate.yml
vendored
@@ -63,6 +63,7 @@ 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
|
||||
@@ -129,8 +130,11 @@ 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
|
||||
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 }}
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
|
||||
3
.gitignore
vendored
@@ -42,6 +42,9 @@ 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/*
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"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
|
||||
@@ -369,7 +370,14 @@
|
||||
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
|
||||
"rules": {
|
||||
"actual/no-untranslated-strings": "off",
|
||||
"actual/prefer-logger-over-console": "off"
|
||||
"actual/prefer-logger-over-console": "off",
|
||||
"typescript/unbound-method": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/eslint-plugin-actual/lib/rules/__tests__/**/*"],
|
||||
"rules": {
|
||||
"actual/enforce-boundaries": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -281,7 +281,6 @@ 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:**
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
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).
|
||||
|
||||
@@ -4,21 +4,30 @@ ROOT=`dirname $0`
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
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
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git checkout .
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
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"
|
||||
lage build:browser --to=@actual-app/web
|
||||
|
||||
@@ -57,8 +57,7 @@ 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 workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
|
||||
@@ -25,6 +25,14 @@ 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: {
|
||||
|
||||
16
package.json
@@ -24,18 +24,16 @@
|
||||
"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:browser-backend build:plugins-service",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron 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": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
|
||||
"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",
|
||||
@@ -61,13 +59,16 @@
|
||||
"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": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"@yarnpkg/types": "^4.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-perfectionist": "^5.8.0",
|
||||
@@ -98,8 +99,9 @@
|
||||
"socks": ">=2.8.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"packages/*/package.json": [
|
||||
"ts-node ./bin/validate-publish-imports.ts --fix"
|
||||
"packages/*/{package.json,tsconfig.json}": [
|
||||
"ts-node ./bin/validate-publish-imports.ts --fix",
|
||||
"yarn sync:tsconfig-references"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml,yaml}": [
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
|
||||
@@ -6,6 +6,11 @@ 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(
|
||||
|
||||
1
packages/api/models.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type * from '@actual-app/core/server/api-models';
|
||||
@@ -3,9 +3,16 @@
|
||||
"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"
|
||||
"dist",
|
||||
"!@types/**/*.test.d.ts",
|
||||
"!@types/**/*.test.d.ts.map"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
@@ -14,6 +21,11 @@
|
||||
"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": {
|
||||
@@ -21,6 +33,10 @@
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./models": {
|
||||
"types": "./@types/models.d.ts",
|
||||
"default": "./dist/models.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,7 +52,7 @@
|
||||
"compare-versions": "^6.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.5",
|
||||
|
||||
@@ -15,15 +15,26 @@
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
"plugins": [
|
||||
{
|
||||
"name": "typescript-strict-plugin",
|
||||
"paths": ["."]
|
||||
}
|
||||
]
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"references": [
|
||||
{
|
||||
"path": "../loot-core"
|
||||
},
|
||||
{
|
||||
"path": "../crdt"
|
||||
}
|
||||
],
|
||||
"include": ["."],
|
||||
"exclude": [
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.test.ts",
|
||||
"*.config.ts",
|
||||
"*.config.mts"
|
||||
]
|
||||
|
||||
@@ -66,9 +66,12 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'index.ts'),
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'index.ts'),
|
||||
models: path.resolve(__dirname, 'models.ts'),
|
||||
},
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'index.js',
|
||||
fileName: (_format, entryName) => `${entryName}.js`,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -69,6 +69,8 @@ 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}`, {
|
||||
@@ -79,17 +81,34 @@ await group('Prepare branch', async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 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`,
|
||||
// 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}`,
|
||||
);
|
||||
const hash = commitHash.trim();
|
||||
if (hash) {
|
||||
console.log(`Dropping previous release notes commit ${hash}`);
|
||||
await exec(`git rebase --onto ${hash}~1 ${hash}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,13 +126,14 @@ if (files.length === 0) {
|
||||
|
||||
const highlights = '- TODO: Add release highlights';
|
||||
|
||||
await group('Generate blog post', async () => {
|
||||
const blogPath = join(
|
||||
'packages/docs/blog',
|
||||
`${releaseDate}-release-${slug}.md`,
|
||||
);
|
||||
const blogPath = join(
|
||||
'packages/docs/blog',
|
||||
`${releaseDate}-release-${slug}.md`,
|
||||
);
|
||||
const releasesPath = 'packages/docs/docs/releases.md';
|
||||
|
||||
const blogContent = `---
|
||||
await group('Generate blog post', async () => {
|
||||
const template = `---
|
||||
title: Release ${version}
|
||||
description: New release of Actual.
|
||||
date: ${releaseDate}T10:00
|
||||
@@ -129,18 +149,60 @@ ${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 newSection = `## ${version}
|
||||
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}
|
||||
|
||||
Release date: ${releaseDate}
|
||||
|
||||
@@ -148,12 +210,14 @@ ${highlights}
|
||||
|
||||
**Docker Tag: ${version}**
|
||||
|
||||
${categorizedNotes}`;
|
||||
${AUTOGEN_MARKER}
|
||||
|
||||
const updated = existing.replace(
|
||||
'# Release Notes\n',
|
||||
`# Release Notes\n\n${newSection}\n`,
|
||||
);
|
||||
${categorizedNotes}`;
|
||||
updated = existing.replace(
|
||||
'# Release Notes\n',
|
||||
`# Release Notes\n\n${newSection}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(releasesPath, updated);
|
||||
console.log(`Updated ${releasesPath}`);
|
||||
@@ -165,13 +229,28 @@ 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 --force-with-lease origin', { stdio: 'inherit' });
|
||||
await exec('git push origin', { stdio: 'inherit' });
|
||||
});
|
||||
|
||||
async function parseReleaseNotes(dir) {
|
||||
@@ -205,6 +284,10 @@ 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)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"extensionless": "^2.0.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"listify": "^1.0.3",
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"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"
|
||||
@@ -36,7 +41,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/proper-lockfile": "^4",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -56,7 +57,11 @@ export function writeCacheState(metaDir: string, state: CacheState): void {
|
||||
try {
|
||||
mkdirSync(metaDir, { recursive: true });
|
||||
const target = cachePath(metaDir);
|
||||
const tmp = `${target}.tmp`;
|
||||
// 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`;
|
||||
writeFileSync(tmp, JSON.stringify(state));
|
||||
renameSync(tmp, target);
|
||||
} catch {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function registerBudgetsCommand(program: Command) {
|
||||
opts,
|
||||
async config => {
|
||||
const password =
|
||||
config.encryptionPassword ?? cmdOpts.encryptionPassword;
|
||||
cmdOpts.encryptionPassword ?? config.encryptionPassword;
|
||||
await api.downloadBudget(syncId, {
|
||||
password,
|
||||
});
|
||||
|
||||
@@ -57,10 +57,10 @@ export function registerSyncCommand(program: Command) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ageSeconds = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - state.lastSyncedAt) / 1000),
|
||||
const rawAgeSeconds = 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: ageSeconds > config.cacheTtl,
|
||||
stale: rawAgeSeconds < 0 || rawAgeSeconds > config.cacheTtl,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
|
||||
@@ -215,16 +215,31 @@ describe('resolveConfig', () => {
|
||||
expect(config.refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('sets refresh when noCache is true', async () => {
|
||||
const config = await resolveConfig({ noCache: true });
|
||||
it('sets refresh when --no-cache is passed (cliOpts.cache === false)', async () => {
|
||||
const config = await resolveConfig({ cache: false });
|
||||
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({});
|
||||
@@ -237,6 +252,11 @@ 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',
|
||||
|
||||
@@ -28,8 +28,10 @@ export type CliGlobalOpts = {
|
||||
cacheTtl?: number;
|
||||
lockTimeout?: number;
|
||||
refresh?: boolean;
|
||||
noCache?: boolean;
|
||||
noLock?: boolean;
|
||||
// Commander stores --no-foo flags under the positive key. Default true,
|
||||
// false when the flag is passed.
|
||||
cache?: boolean;
|
||||
lock?: boolean;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
@@ -208,11 +210,12 @@ export async function resolveConfig(
|
||||
'lockTimeout',
|
||||
);
|
||||
|
||||
const refresh = cliOpts.refresh ?? cliOpts.noCache ?? false;
|
||||
const refresh = (cliOpts.refresh ?? false) || cliOpts.cache === false;
|
||||
|
||||
const flagNoLock = cliOpts.lock === false ? true : undefined;
|
||||
const noLock =
|
||||
cliOpts.noLock ??
|
||||
parseBoolEnv(process.env.ACTUAL_NO_LOCK) ??
|
||||
flagNoLock ??
|
||||
parseBoolEnv(process.env.ACTUAL_NO_LOCK, 'ACTUAL_NO_LOCK') ??
|
||||
fileConfig.noLock ??
|
||||
false;
|
||||
|
||||
|
||||
@@ -23,14 +23,11 @@ function info(message: string, verbose?: boolean) {
|
||||
}
|
||||
|
||||
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
|
||||
const budgets = (await api.getBudgets()) as Array<{
|
||||
id?: string;
|
||||
groupId?: string;
|
||||
cloudFileId?: string;
|
||||
}>;
|
||||
const budgets = await api.getBudgets();
|
||||
const match = budgets.find(
|
||||
b =>
|
||||
b.id !== undefined && (b.groupId === syncId || b.cloudFileId === syncId),
|
||||
typeof b.id === 'string' &&
|
||||
(b.groupId === syncId || b.cloudFileId === syncId),
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw new Error(
|
||||
|
||||
@@ -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', false)
|
||||
.option('--no-cache', 'Alias for --refresh')
|
||||
.option(
|
||||
'--lock-timeout <seconds>',
|
||||
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
|
||||
@@ -49,7 +49,6 @@ 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')
|
||||
|
||||
@@ -32,9 +32,15 @@ export function parseNonNegativeIntFlag(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBoolEnv(raw: string | undefined): boolean | undefined {
|
||||
export function parseBoolEnv(
|
||||
raw: string | undefined,
|
||||
source: string,
|
||||
): boolean | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === '1' || raw.toLowerCase() === 'true') return true;
|
||||
if (raw === '0' || raw.toLowerCase() === 'false') return false;
|
||||
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".`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ 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 = {
|
||||
|
||||
@@ -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": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint-plugin-storybook": "^10.3.4",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
"description": "CRDT layer of Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"!dist/**/*.test.d.ts",
|
||||
"!dist/**/*.test.d.ts.map",
|
||||
"!dist/**/*.spec.d.ts",
|
||||
"!dist/**/*.spec.d.ts.map"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -26,7 +30,7 @@
|
||||
"scripts": {
|
||||
"build:node": "vite build",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
|
||||
"build": "yarn run build:node && tsgo -b",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
@@ -36,7 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
1
packages/desktop-client/.gitignore
vendored
@@ -8,6 +8,7 @@ coverage
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.playwright-cli
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
97
packages/desktop-client/bin/serve-build.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/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}`);
|
||||
});
|
||||
210
packages/desktop-client/bin/validate-theme-catalog.mts
Normal file
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
});
|
||||
@@ -86,7 +86,14 @@ test.describe('Accounts', () => {
|
||||
credit: '34.56',
|
||||
});
|
||||
|
||||
await page.waitForTimeout(100); // Give time for the previous transaction to be rendered
|
||||
// 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 accountPage.selectNthTransaction(0);
|
||||
await accountPage.selectNthTransaction(1);
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,10 +1,57 @@
|
||||
import { expect as baseExpect } from '@playwright/test';
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { test as base, expect as baseExpect } from '@playwright/test';
|
||||
import type { Browser, Locator, Page } from '@playwright/test';
|
||||
|
||||
export { test } 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 const expect = baseExpect.extend({
|
||||
async toMatchThemeScreenshots(locator: Locator) {
|
||||
async toMatchThemeScreenshots(target: Locator | Page) {
|
||||
// Disable screenshot assertions in regular e2e tests;
|
||||
// only enable them when doing VRT tests
|
||||
if (!process.env.VRT) {
|
||||
@@ -15,38 +62,33 @@ export const expect = baseExpect.extend({
|
||||
}
|
||||
|
||||
const config = {
|
||||
mask: [locator.locator('[data-vrt-mask="true"]')],
|
||||
mask: [target.locator('[data-vrt-mask="true"]')],
|
||||
maxDiffPixels: 5,
|
||||
};
|
||||
|
||||
// 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]');
|
||||
const page: Page = 'page' in target ? target.page() : target;
|
||||
const dataThemeLocator = page.locator('[data-theme]');
|
||||
|
||||
// Check lightmode
|
||||
await locator.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await page.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch to darkmode and check
|
||||
await locator.evaluate(() => window.Actual.setTheme('dark'));
|
||||
await page.evaluate(() => window.Actual.setTheme('dark'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch to midnight theme and check
|
||||
await locator.evaluate(() => window.Actual.setTheme('midnight'));
|
||||
await page.evaluate(() => window.Actual.setTheme('midnight'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute(
|
||||
'data-theme',
|
||||
'midnight',
|
||||
);
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch back to lightmode
|
||||
await locator.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await page.evaluate(() => window.Actual.setTheme('auto'));
|
||||
return {
|
||||
message: () => 'pass',
|
||||
pass: true,
|
||||
|
||||
@@ -16,6 +16,15 @@ 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$/)
|
||||
|
||||
@@ -16,12 +16,18 @@ export class ConfigurationPage {
|
||||
|
||||
async createTestFile() {
|
||||
await this.page.getByRole('button', { name: 'Create test file' }).click();
|
||||
return new BudgetPage(this.page);
|
||||
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;
|
||||
}
|
||||
|
||||
async createDemoFile() {
|
||||
await this.page.getByRole('button', { name: 'View demo' }).click();
|
||||
return new BudgetPage(this.page);
|
||||
const budgetPage = new BudgetPage(this.page);
|
||||
await budgetPage.waitFor();
|
||||
return budgetPage;
|
||||
}
|
||||
|
||||
async clickOnNoServer() {
|
||||
@@ -37,7 +43,9 @@ export class ConfigurationPage {
|
||||
async startFresh() {
|
||||
await this.page.getByRole('button', { name: 'Start fresh' }).click();
|
||||
|
||||
return new AccountPage(this.page);
|
||||
const accountPage = new AccountPage(this.page);
|
||||
await accountPage.accountName.waitFor();
|
||||
return accountPage;
|
||||
}
|
||||
|
||||
async importBudget(type: 'YNAB4' | 'nYNAB' | 'Actual', file: string) {
|
||||
|
||||
@@ -312,21 +312,14 @@ export class MobileBudgetPage {
|
||||
async #getButtonForEnvelopeBudgetSummary({
|
||||
throwIfNotFound = true,
|
||||
}: { throwIfNotFound?: boolean } = {}) {
|
||||
if (await this.toBudgetButton.isVisible()) {
|
||||
return this.toBudgetButton;
|
||||
const button = this.toBudgetButton.or(this.overbudgetedButton).first();
|
||||
try {
|
||||
await button.waitFor();
|
||||
} catch (err) {
|
||||
if (!throwIfNotFound) return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
return button;
|
||||
}
|
||||
|
||||
async openEnvelopeBudgetSummary() {
|
||||
@@ -346,25 +339,17 @@ export class MobileBudgetPage {
|
||||
async #getButtonForTrackingBudgetSummary({
|
||||
throwIfNotFound = true,
|
||||
}: { throwIfNotFound?: boolean } = {}) {
|
||||
if (await this.savedButton.isVisible()) {
|
||||
return this.savedButton;
|
||||
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.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.',
|
||||
);
|
||||
return button;
|
||||
}
|
||||
|
||||
async openTrackingBudgetSummary() {
|
||||
|
||||
@@ -131,7 +131,9 @@ export class MobileNavigation {
|
||||
}
|
||||
|
||||
const link = this.navbar.getByRole('link', { name: pageName });
|
||||
await link.click();
|
||||
// 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 pageInstance.waitFor();
|
||||
|
||||
|
||||
@@ -25,10 +25,31 @@ 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();
|
||||
await this.page.locator('css=[role=combobox] input').fill(content);
|
||||
await this.page.keyboard.press('Enter');
|
||||
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();
|
||||
}
|
||||
|
||||
async createTransaction() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { AccountPage } from './account-page';
|
||||
import { BankSyncPage } from './bank-sync-page';
|
||||
@@ -14,6 +14,55 @@ 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;
|
||||
|
||||
@@ -95,22 +144,34 @@ export class Navigation {
|
||||
|
||||
async createAccount(data: AccountEntry) {
|
||||
await this.page.getByRole('button', { name: 'Add account' }).click();
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Create a local account' })
|
||||
.click();
|
||||
|
||||
// Fill the form
|
||||
await this.page.getByLabel('Name:').fill(data.name);
|
||||
await this.page.getByLabel('Balance:').fill(String(data.balance));
|
||||
// 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' });
|
||||
|
||||
await fillReactInput(this.page.getByLabel('Name'), data.name);
|
||||
await fillReactInput(this.page.getByLabel('Balance'), String(data.balance));
|
||||
|
||||
if (data.offBudget) {
|
||||
await this.page.getByLabel('Off budget').click();
|
||||
}
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Create', exact: true })
|
||||
.click();
|
||||
return new AccountPage(this.page);
|
||||
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;
|
||||
}
|
||||
|
||||
async clickOnNoServer() {
|
||||
|
||||
@@ -46,13 +46,11 @@ export class ScheduleEditModal {
|
||||
}
|
||||
|
||||
if (data.payee) {
|
||||
await this.payeeInput.pressSequentially(data.payee);
|
||||
await this.page.keyboard.press('Enter');
|
||||
await this.#typeAndSelectOption(this.payeeInput, data.payee);
|
||||
}
|
||||
|
||||
if (data.account) {
|
||||
await this.accountInput.pressSequentially(data.account);
|
||||
await this.page.keyboard.press('Enter');
|
||||
await this.#typeAndSelectOption(this.accountInput, data.account);
|
||||
}
|
||||
|
||||
if (data.amount) {
|
||||
@@ -60,6 +58,16 @@ 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();
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -32,7 +32,7 @@ test.describe('Mobile Transactions', () => {
|
||||
|
||||
await expect(transactionEntryPage.header).toHaveText('New Transaction');
|
||||
|
||||
await transactionEntryPage.amountField.fill('12.34');
|
||||
await transactionEntryPage.fillAmount('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.amountField.fill('12.34');
|
||||
await transactionEntryPage.fillAmount('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.amountField.fill('12.35');
|
||||
await transactionEntryPage.fillAmount('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.amountField.fill('12.34');
|
||||
await transactionEntryPage.fillAmount('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.amountField.fill('12.35');
|
||||
await transactionEntryPage.fillAmount('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.amountField.fill('12.34');
|
||||
await transactionEntryPage.fillAmount('12.34');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
9
packages/desktop-client/e2e/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"types": ["@playwright/test", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../../loot-core/typings/window.ts"]
|
||||
}
|
||||
@@ -86,7 +86,10 @@
|
||||
'Arial',
|
||||
sans-serif
|
||||
);
|
||||
font-feature-settings: 'ss01', 'ss04';
|
||||
font-feature-settings:
|
||||
'ss01',
|
||||
'ss04',
|
||||
'calt' 0;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"name": "@actual-app/web",
|
||||
"version": "26.4.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actualbudget/actual.git",
|
||||
"directory": "packages/desktop-client"
|
||||
},
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
@@ -25,9 +30,13 @@
|
||||
"#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/bankSyncUtils": "./src/components/banksync/bankSyncUtils.ts",
|
||||
"#components/banksync/BuiltInProviders": "./src/components/banksync/BuiltInProviders.tsx",
|
||||
"#components/banksync/useBuiltInBankSyncProviders": "./src/components/banksync/useBuiltInBankSyncProviders.ts",
|
||||
"#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",
|
||||
@@ -98,9 +107,10 @@
|
||||
"start:browser": "cross-env ./bin/watch-browser",
|
||||
"watch": "cross-env BROWSER=none yarn start",
|
||||
"build": "vite build",
|
||||
"build:browser": "cross-env ./bin/build-browser",
|
||||
"build:browser": "vite build --mode=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",
|
||||
@@ -134,7 +144,7 @@
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@typescript/native-preview": "beta",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
@@ -157,6 +167,7 @@
|
||||
"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",
|
||||
|
||||
@@ -6,31 +6,40 @@ export default defineConfig({
|
||||
timeout: 60000, // 60 seconds
|
||||
retries: 1,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
testDir: 'e2e/',
|
||||
reporter: process.env.CI
|
||||
? [['blob'], ['list']]
|
||||
: [['html', { open: 'never' }]],
|
||||
use: {
|
||||
userAgent: 'playwright',
|
||||
screenshot: 'on',
|
||||
screenshot: 'only-on-failure',
|
||||
browserName: 'chromium',
|
||||
baseURL: process.env.E2E_START_URL ?? 'http://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
expect: {
|
||||
// Default expect timeout (5s) is too tight for initial render of the
|
||||
// budget page in the production bundle under CI CPU contention —
|
||||
// the budget-table testid lives inside AutoSizer, which returns null
|
||||
// until layout provides width/height, and that can take >5s. Bumping
|
||||
// to 10s lets those assertions settle without per-test overrides.
|
||||
timeout: 10_000,
|
||||
toHaveScreenshot: { maxDiffPixels: 5 },
|
||||
},
|
||||
webServer: process.env.E2E_START_URL
|
||||
? undefined
|
||||
: {
|
||||
cwd: path.join(__dirname, '..', '..'),
|
||||
command: 'yarn start',
|
||||
command: process.env.E2E_USE_BUILD
|
||||
? 'node packages/desktop-client/bin/serve-build.mjs'
|
||||
: 'yarn start',
|
||||
url: 'http://localhost:3001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
ignoreHTTPSErrors: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,13 +44,19 @@ function makeSchedule(
|
||||
} satisfies ScheduleEntity;
|
||||
}
|
||||
|
||||
function mockedSchedules(schedules: ScheduleEntity[]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
schedules,
|
||||
statuses: new Map(),
|
||||
statusLabels: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SelectedBalance – normal transactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(mockedSchedules([]));
|
||||
});
|
||||
|
||||
test('shows balance for selected normal transactions', () => {
|
||||
@@ -93,10 +99,9 @@ describe('SelectedBalance – preview (scheduled) transactions', () => {
|
||||
vi.mocked(useSelectedItems).mockReturnValue(
|
||||
new Set([`preview/${scheduleId}/2026-03-24`]),
|
||||
);
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(
|
||||
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
@@ -116,10 +121,9 @@ describe('SelectedBalance – preview (scheduled) transactions', () => {
|
||||
const selectedItems = new Set([previewId1, previewId2]);
|
||||
|
||||
vi.mocked(useSelectedItems).mockReturnValue(selectedItems);
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(
|
||||
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
|
||||
@@ -230,14 +230,20 @@ export function AccountHeader({
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+f, cmd+f, meta+f',
|
||||
() => {
|
||||
e => {
|
||||
if (searchInput.current) {
|
||||
searchInput.current.focus();
|
||||
// Trigger browser-native find if user pressed search twice in a row
|
||||
if (document.activeElement === searchInput.current) {
|
||||
searchInput.current.blur();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
searchInput.current.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
preventDefault: false,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[searchInput],
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Warning } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
|
||||
import type { BuiltInBankSyncProviderState } from './useBuiltInBankSyncProviders';
|
||||
|
||||
type BuiltInProvidersProps = {
|
||||
providers: BuiltInBankSyncProviderState[];
|
||||
syncServerStatus: 'offline' | 'no-server' | 'online';
|
||||
showPermissionWarning: boolean;
|
||||
providersNeedingConfiguration: BuiltInBankSyncProviderState[];
|
||||
};
|
||||
|
||||
export function BuiltInProviders({
|
||||
providers,
|
||||
syncServerStatus,
|
||||
showPermissionWarning,
|
||||
providersNeedingConfiguration,
|
||||
}: BuiltInProvidersProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ fontSize: 20, fontWeight: 600 }}>
|
||||
<Trans>Providers</Trans>
|
||||
</Text>
|
||||
<Paragraph style={{ fontSize: 15, color: theme.pageTextSubdued }}>
|
||||
<Trans>
|
||||
Set up a bank sync provider, then link new accounts or connect an
|
||||
existing Actual account.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
</View>
|
||||
|
||||
{syncServerStatus !== 'online' ? (
|
||||
<View
|
||||
style={{
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<Button isDisabled style={{ padding: '10px 0', fontSize: 15 }}>
|
||||
<Trans>Set up bank sync</Trans>
|
||||
</Button>
|
||||
<Paragraph style={{ fontSize: 15, marginTop: 10 }}>
|
||||
<Trans>
|
||||
Connect to an Actual server to set up{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/advanced/bank-sync"
|
||||
linkColor="muted"
|
||||
>
|
||||
automatic syncing
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{providers.map(provider => (
|
||||
<View
|
||||
key={provider.id}
|
||||
data-testid={`bank-sync-provider-${provider.id}`}
|
||||
style={{
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
backgroundColor: theme.tableBackground,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
gap: 6,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 17, fontWeight: 600 }}>
|
||||
{provider.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: provider.isConfigured
|
||||
? theme.noticeTextDark
|
||||
: theme.pageTextSubdued,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{provider.isConfigured ? (
|
||||
<Trans>Configured</Trans>
|
||||
) : (
|
||||
<Trans>Not configured</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{provider.isConfigured && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('{{provider}} menu', {
|
||||
provider: provider.displayName,
|
||||
})}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
void provider.onReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset {{provider}} credentials', {
|
||||
provider: provider.displayName,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
isDisabled={!provider.canConfigure}
|
||||
onPress={() => provider.onConfigure()}
|
||||
>
|
||||
{provider.isConfigured ? (
|
||||
<Trans>Edit setup</Trans>
|
||||
) : (
|
||||
<Trans>Set up</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isDisabled={!provider.isConfigured}
|
||||
isLoading={provider.isLoading}
|
||||
onPress={() => provider.onLink()}
|
||||
>
|
||||
<Trans>Link bank account</Trans>
|
||||
</ButtonWithLoading>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showPermissionWarning && (
|
||||
<Warning>
|
||||
<Trans>
|
||||
You don't have the required permissions to configure bank sync
|
||||
providers. Please contact an Admin to configure
|
||||
</Trans>{' '}
|
||||
{providersNeedingConfiguration
|
||||
.map(provider => provider.displayName)
|
||||
.join(' or ')}
|
||||
.
|
||||
</Warning>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||