Compare commits
253 Commits
v23.10.0
...
ts-LoadBac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e71dcccd8 | ||
|
|
de9a1880a7 | ||
|
|
43ebe9e0fd | ||
|
|
515bdf5a74 | ||
|
|
018714610a | ||
|
|
00ee165f8e | ||
|
|
68442ae9e6 | ||
|
|
b937bfae04 | ||
|
|
317e7f135e | ||
|
|
5adb083575 | ||
|
|
524bd4e9eb | ||
|
|
9dfd6ce34c | ||
|
|
5d28bc0e3b | ||
|
|
a4e97e0070 | ||
|
|
a6e38ad2ae | ||
|
|
bb0ae4ebc3 | ||
|
|
dd29e02c5c | ||
|
|
75186183eb | ||
|
|
83f13cbdc8 | ||
|
|
0045d9212e | ||
|
|
dd254c6c23 | ||
|
|
c66d6e00f5 | ||
|
|
ae3be13aa8 | ||
|
|
cd9d1fb674 | ||
|
|
edba670d3a | ||
|
|
fbb1a9647d | ||
|
|
06cf65497f | ||
|
|
62a0a0fedc | ||
|
|
106dce25dd | ||
|
|
ff11399180 | ||
|
|
363c9e4afb | ||
|
|
e745a4073b | ||
|
|
7d1a895b48 | ||
|
|
a8b42bcd50 | ||
|
|
f33bce41ea | ||
|
|
cdefe6133f | ||
|
|
44a5199a31 | ||
|
|
dccad902d6 | ||
|
|
b477f7c2f1 | ||
|
|
540c410957 | ||
|
|
e205344867 | ||
|
|
8b968579b1 | ||
|
|
6ce794ffcc | ||
|
|
d5359a96ca | ||
|
|
3eee0b11d2 | ||
|
|
e792afb1fd | ||
|
|
4fb55d0d70 | ||
|
|
b330991855 | ||
|
|
165ad45822 | ||
|
|
295917036b | ||
|
|
ef9a7cfe85 | ||
|
|
4ece4a7ff6 | ||
|
|
761b3c6a16 | ||
|
|
7ace8c52dd | ||
|
|
f6dd0ecdb9 | ||
|
|
97a4296d7c | ||
|
|
89698480a5 | ||
|
|
1a6db82cfb | ||
|
|
6ce502ea24 | ||
|
|
fd962a97b0 | ||
|
|
300ed824f0 | ||
|
|
caca2497ea | ||
|
|
33e778fe9f | ||
|
|
8c43c78fc7 | ||
|
|
e0d82fd4f9 | ||
|
|
b6462347a9 | ||
|
|
8bf0f8e5bf | ||
|
|
bc07235017 | ||
|
|
794476ac51 | ||
|
|
30bc216142 | ||
|
|
6f8d2574ab | ||
|
|
9262b46428 | ||
|
|
8e1c11e18e | ||
|
|
6b5ee3f774 | ||
|
|
02d3f96c20 | ||
|
|
829c83afb2 | ||
|
|
5d29585fb7 | ||
|
|
882fd9f5cd | ||
|
|
d203def230 | ||
|
|
4d7cfab8bc | ||
|
|
84a9269ae4 | ||
|
|
83d2472a55 | ||
|
|
458d556e51 | ||
|
|
d9aeb8db1e | ||
|
|
85c0352abb | ||
|
|
7a40a645e6 | ||
|
|
7e9921e9e5 | ||
|
|
02aff1acbe | ||
|
|
e0d66d3083 | ||
|
|
d999e466b7 | ||
|
|
e589163bb7 | ||
|
|
d2b86d100c | ||
|
|
d8996405c4 | ||
|
|
e548125907 | ||
|
|
edbcaba89b | ||
|
|
b39d106c04 | ||
|
|
4894118809 | ||
|
|
c6723da780 | ||
|
|
7a8c320560 | ||
|
|
6fbf0fdc10 | ||
|
|
efaefcfaa9 | ||
|
|
73d281b6ee | ||
|
|
03e943f383 | ||
|
|
5854dffd50 | ||
|
|
c727e3e980 | ||
|
|
b385c715ef | ||
|
|
77e550f028 | ||
|
|
9ea8e86bb3 | ||
|
|
273fa8cd59 | ||
|
|
d4c8b5fa16 | ||
|
|
e62e8ca24e | ||
|
|
4497fbcb10 | ||
|
|
8a15caf42d | ||
|
|
af9efa09f0 | ||
|
|
a5557ca032 | ||
|
|
0bf587bcf5 | ||
|
|
e11b65719e | ||
|
|
c09a85f340 | ||
|
|
f90fe04b2b | ||
|
|
ca55d9c85e | ||
|
|
4ada92071e | ||
|
|
e848946898 | ||
|
|
9da05543c3 | ||
|
|
8a721bf2e0 | ||
|
|
36914abcd4 | ||
|
|
cd2d186599 | ||
|
|
b3bcff094d | ||
|
|
7955fe7aed | ||
|
|
2741422c0a | ||
|
|
cde4551eb7 | ||
|
|
7174fccfe4 | ||
|
|
0303292a28 | ||
|
|
f95df06800 | ||
|
|
3680d45814 | ||
|
|
dc872647a9 | ||
|
|
7a45d467b7 | ||
|
|
79aa07ff1a | ||
|
|
c4ff099f26 | ||
|
|
ad2b577515 | ||
|
|
5f52801869 | ||
|
|
6bfd9586e0 | ||
|
|
f36713e0be | ||
|
|
8b20169b59 | ||
|
|
da03acc394 | ||
|
|
5d1f2d48ae | ||
|
|
c96782d7c1 | ||
|
|
5d61dfce68 | ||
|
|
5a81a25b7e | ||
|
|
29f323e721 | ||
|
|
c462604833 | ||
|
|
e48f36cd20 | ||
|
|
1ed84059d7 | ||
|
|
5d8b96a749 | ||
|
|
0920bdc3fa | ||
|
|
c3a9b4dda3 | ||
|
|
eab0068428 | ||
|
|
0d1b962b2f | ||
|
|
67b9143dfc | ||
|
|
e7f298e32a | ||
|
|
516f0c259f | ||
|
|
2c87c60328 | ||
|
|
b4fd6e86ed | ||
|
|
a3e71a8b49 | ||
|
|
aa9c992f2e | ||
|
|
ca498b19cc | ||
|
|
8be2389fd9 | ||
|
|
46988800ef | ||
|
|
881999bcfe | ||
|
|
9c124e8e44 | ||
|
|
3306963c54 | ||
|
|
baa474843a | ||
|
|
6ed1b58321 | ||
|
|
28266ed9a2 | ||
|
|
cd6bd9ece8 | ||
|
|
fb2f712c16 | ||
|
|
f58fae8d16 | ||
|
|
a10b10a87b | ||
|
|
47007232b8 | ||
|
|
4761e9ce2f | ||
|
|
849262c95e | ||
|
|
e6e184c412 | ||
|
|
9c6d9ecf0a | ||
|
|
65273127f5 | ||
|
|
b5530085bb | ||
|
|
9ec82d52c9 | ||
|
|
93922567fc | ||
|
|
7c0b7a2f17 | ||
|
|
e8055bbc35 | ||
|
|
7fdd9767ba | ||
|
|
1f105999af | ||
|
|
f970263c72 | ||
|
|
fecc9178b3 | ||
|
|
08c80b6f58 | ||
|
|
a3995582c4 | ||
|
|
5008eb022f | ||
|
|
353e7be31f | ||
|
|
30c7024663 | ||
|
|
22efe74ec8 | ||
|
|
5792b15cb5 | ||
|
|
168b79a178 | ||
|
|
7062f2a7d9 | ||
|
|
af666458d3 | ||
|
|
184b302273 | ||
|
|
3004f336da | ||
|
|
45bb7696c3 | ||
|
|
199a9f838d | ||
|
|
df5aa3186c | ||
|
|
1c68c3f964 | ||
|
|
a15f80e771 | ||
|
|
be18891957 | ||
|
|
a6dae398da | ||
|
|
d8c885bf4e | ||
|
|
f94d8aff9f | ||
|
|
a549cdf0e7 | ||
|
|
b7a2f16246 | ||
|
|
0ec88ecc24 | ||
|
|
39fa9f2097 | ||
|
|
9040cab600 | ||
|
|
e56cb4bc85 | ||
|
|
fc08baf7ae | ||
|
|
7325153da1 | ||
|
|
e12793e7eb | ||
|
|
5fe146ee0a | ||
|
|
0af2987cef | ||
|
|
e266093a4a | ||
|
|
19f0efb654 | ||
|
|
e0b2eab475 | ||
|
|
25f4e2a8b5 | ||
|
|
d338a73794 | ||
|
|
75f2bf8b1b | ||
|
|
eb54487d8e | ||
|
|
5cc6bad34b | ||
|
|
acf4456077 | ||
|
|
b45615e6fc | ||
|
|
38609ee25a | ||
|
|
cae2b9cd5a | ||
|
|
4cacc845c0 | ||
|
|
3dfbd23f96 | ||
|
|
21effa654d | ||
|
|
c33dc6fbad | ||
|
|
057caf127a | ||
|
|
767bc8ecb6 | ||
|
|
bdf5c45cda | ||
|
|
3dfe633428 | ||
|
|
efba86a72d | ||
|
|
316da144e1 | ||
|
|
d3ab8f9812 | ||
|
|
0ea7f1852b | ||
|
|
3c341fc583 | ||
|
|
de90504a6d | ||
|
|
f6e2d3b1f3 | ||
|
|
f1973d55c0 | ||
|
|
476070b0a7 |
@@ -1,5 +1,6 @@
|
||||
packages/api/app/bundle.api.js
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/api/migrations
|
||||
|
||||
packages/crdt/dist
|
||||
@@ -24,7 +25,3 @@ packages/import-ynab5/**/node_modules/*
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
|
||||
packages/node-libofx/libofx.*.js
|
||||
packages/node-libofx/libofx/
|
||||
packages/node-libofx/OpenSP-*/
|
||||
|
||||
62
.eslintrc.js
@@ -38,18 +38,25 @@ module.exports = {
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/typescript',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
|
||||
reportUnusedDisableDirectives: true,
|
||||
globals: {
|
||||
globalThis: false,
|
||||
vi: true,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: 'none',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
@@ -61,8 +68,16 @@ module.exports = {
|
||||
require('confusing-browser-globals').filter(g => g !== 'self'),
|
||||
),
|
||||
|
||||
'react/jsx-filename-extension': [
|
||||
'warn',
|
||||
{ extensions: ['.jsx', '.tsx'], allow: 'as-needed' },
|
||||
],
|
||||
'react/jsx-no-useless-fragment': 'warn',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/no-unstable-nested-components': [
|
||||
'warn',
|
||||
{ allowAsProps: true, customValidators: ['formatter'] },
|
||||
],
|
||||
|
||||
'rulesdir/typography': 'warn',
|
||||
'rulesdir/prefer-if-statement': 'warn',
|
||||
@@ -76,7 +91,6 @@ module.exports = {
|
||||
|
||||
// TODO: re-enable these rules
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/no-children-prop': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
// 'react-hooks/exhaustive-deps': [
|
||||
@@ -86,6 +100,10 @@ module.exports = {
|
||||
// },
|
||||
// ],
|
||||
|
||||
'no-var': 'warn',
|
||||
'react/jsx-curly-brace-presence': 'warn',
|
||||
'object-shorthand': ['warn', 'properties'],
|
||||
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'never',
|
||||
@@ -146,11 +164,17 @@ module.exports = {
|
||||
{ patterns: [...restrictedImportPatterns, ...restrictedImportColors] },
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{ 'ts-ignore': 'allow-with-description' },
|
||||
],
|
||||
|
||||
// Rules disable during TS migration
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'prefer-const': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
@@ -186,6 +210,26 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/desktop-client/**/*'],
|
||||
excludedFiles: [
|
||||
'./packages/desktop-client/src/hooks/useNavigate.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['react-router-dom'],
|
||||
importNames: ['useNavigate'],
|
||||
message: 'Please use Actual’s useNavigate() hook instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['./packages/loot-core/src/**/*'],
|
||||
rules: {
|
||||
@@ -224,11 +268,17 @@ module.exports = {
|
||||
'no-restricted-imports': ['off', { patterns: restrictedImportColors }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'./packages/api/migrations/*',
|
||||
'./packages/loot-core/migrations/*',
|
||||
],
|
||||
rules: {
|
||||
'import/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
|
||||
2
.gitattributes
vendored
@@ -16,4 +16,4 @@ yarn.lock text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpg binary
|
||||
|
||||
1
.github/FUNDING.yml
vendored
@@ -1,2 +1,3 @@
|
||||
# Funding policies: https://actualbudget.org/docs/contributing/leadership/funding
|
||||
open_collective: actual
|
||||
github: actualbudget
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -18,6 +18,18 @@ body:
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: bank-sync-issue
|
||||
attributes:
|
||||
label: 'Is this related to GoCardless, Simplefin or another bank-sync provider?'
|
||||
description: 'Most issues with bank-sync providers are due to a lack of a custom bank-mapper (i.e. payee or other fields not coming through). In such cases you can create a custom bank mapper in [actual-server](https://github.com/actualbudget/actual-server/blob/master/src/app-gocardless/README.md) repository. Other likely issue is misconfigured server - in which case please reach out via the [community Discord](https://discord.gg/pRYNYr4W5A) to get support.'
|
||||
options:
|
||||
- label: 'I have checked my server logs and could not see any errors there'
|
||||
- label: 'I will be attaching my server logs to this issue'
|
||||
- label: 'I will be attaching my client-side (browser) logs to this issue'
|
||||
- label: 'I understand that this issue will be automatically closed if insufficient information is provided'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Support
|
||||
url: https://discord.gg/pRYNYr4W5A
|
||||
about: Need help with something? Perhaps having issues setting up bank-sync with GoCardless or SimpleFin? Reach out to the community on Discord.
|
||||
|
||||
4
.github/workflows/e2e-test.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.37.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
needs: netlify
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.37.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
|
||||
50
.github/workflows/electron-master.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Electron
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
run: python3 -m pip install setuptools
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
env:
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
43
.github/workflows/electron-pr.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Electron
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
run: python3 -m pip install setuptools
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
42
.github/workflows/electron.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Electron
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
10
.github/workflows/size-compare.yml
vendored
@@ -64,13 +64,17 @@ jobs:
|
||||
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./head/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./head/*.json
|
||||
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
|
||||
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./base/*.json
|
||||
- uses: github/webpack-bundlesize-compare-action@v1.8.2
|
||||
- uses: twk3/rollup-size-compare-action@v1.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head/desktop-client-stats.json
|
||||
base-stats-json-path: ./base/desktop-client-stats.json
|
||||
current-stats-json-path: ./head/web-stats.json
|
||||
base-stats-json-path: ./base/web-stats.json
|
||||
title: desktop-client
|
||||
|
||||
- uses: github/webpack-bundlesize-compare-action@v1.8.2
|
||||
|
||||
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
!data/.gitkeep
|
||||
/data2
|
||||
packages/api/dist
|
||||
packages/api/@types
|
||||
packages/crdt/dist
|
||||
packages/desktop-electron/client-build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
|
||||
2
.secret-tokens.example
Normal file
@@ -0,0 +1,2 @@
|
||||
export APPLE_ID=example@email.com
|
||||
export APPLE_APP_SPECIFIC_PASSWORD=password
|
||||
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
873
.yarn/releases/yarn-3.5.1.cjs
vendored
893
.yarn/releases/yarn-4.0.1.cjs
vendored
Executable file
12
.yarnrc.yml
@@ -1,9 +1,7 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.5.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.0.1.cjs
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
ROOT=`dirname $0`
|
||||
RELEASE=""
|
||||
RELEASE_NOTES="" # TODO: figure out automation for release notes when we start publishing electron versions
|
||||
CI=${CI:-false}
|
||||
|
||||
cd "$ROOT/.."
|
||||
@@ -47,15 +46,13 @@ yarn workspace desktop-electron update-client
|
||||
cd packages/desktop-electron;
|
||||
yarn clean;
|
||||
|
||||
export npm_config_better_sqlite3_binary_host="https://static.actualbudget.com/prebuild/better-sqlite3"
|
||||
|
||||
if [ "$RELEASE" == "production" ]; then
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build --publish always -c.releaseInfo.releaseNotes="$RELEASE_NOTES" --arm64 --x64
|
||||
yarn build --publish never --arm64 --x64
|
||||
|
||||
echo "\nCreated release with release notes \"$RELEASE_NOTES\""
|
||||
echo "\nCreated release"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
fi
|
||||
|
||||
22
package.json
@@ -30,37 +30,39 @@
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"test": "yarn workspaces foreach --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --parallel --verbose run e2e",
|
||||
"vrt": "yarn workspaces foreach --parallel --verbose run vrt",
|
||||
"test": "yarn workspaces foreach --all --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
|
||||
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint": "eslint . --max-warnings 0 --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
|
||||
"typecheck": "yarn tsc",
|
||||
"typecheck": "yarn tsc && tsc-strict",
|
||||
"jq": "./node_modules/node-jq/bin/jq"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"node-jq": "^4.0.1",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"prettier": "2.8.2",
|
||||
"prettier": "3.2.4",
|
||||
"react-refresh": "^0.14.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.0.2",
|
||||
"typescript-strict-plugin": "^2.2.2-beta.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"packageManager": "yarn@3.5.1",
|
||||
"packageManager": "yarn@4.0.1",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"defaults"
|
||||
|
||||
1
packages/api/.gitignore
vendored
@@ -2,3 +2,4 @@ app/bundle.api.js*
|
||||
app/stats.json
|
||||
migrations
|
||||
default-db.sqlite
|
||||
mocks/budgets/**/*
|
||||
|
||||
21
packages/api/__snapshots__/methods.test.ts.snap
Normal file
@@ -0,0 +1,21 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`API setup and teardown successfully loads budget 1`] = `
|
||||
Array [
|
||||
"2016-10",
|
||||
"2016-11",
|
||||
"2016-12",
|
||||
"2017-01",
|
||||
"2017-02",
|
||||
"2017-03",
|
||||
"2017-04",
|
||||
"2017-05",
|
||||
"2017-06",
|
||||
"2017-07",
|
||||
"2017-08",
|
||||
"2017-09",
|
||||
"2017-10",
|
||||
"2017-11",
|
||||
"2017-12",
|
||||
]
|
||||
`;
|
||||
@@ -23,7 +23,7 @@ class Query {
|
||||
}
|
||||
|
||||
unfilter(exprs) {
|
||||
let exprSet = new Set(exprs);
|
||||
const exprSet = new Set(exprs);
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
@@ -37,13 +37,13 @@ class Query {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
let query = new Query({ ...this.state, selectExpressions: exprs });
|
||||
const query = new Query({ ...this.state, selectExpressions: exprs });
|
||||
query.state.calculation = false;
|
||||
return query;
|
||||
}
|
||||
|
||||
calculate(expr) {
|
||||
let query = this.select({ result: expr });
|
||||
const query = this.select({ result: expr });
|
||||
query.state.calculation = true;
|
||||
return query;
|
||||
}
|
||||
@@ -99,6 +99,6 @@ class Query {
|
||||
}
|
||||
}
|
||||
|
||||
export default function q(table) {
|
||||
export function q(table) {
|
||||
return new Query({ table });
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/* eslint-disable import/no-unused-modules */
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
|
||||
let actualApp;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
// DEPRECATED: remove the next line in @actual-app/api v7
|
||||
export * as methods from './methods';
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
global.fetch = (...args) =>
|
||||
import('node-fetch').then(({ default: fetch }) => fetch(...args));
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('sync');
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
53
packages/api/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
RequestInfo as FetchInfo,
|
||||
RequestInit as FetchInit,
|
||||
// @ts-ignore: false-positive commonjs module error on build until typescript 5.3
|
||||
} from 'node-fetch'; // with { 'resolution-mode': 'import' };
|
||||
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
// eslint-disable-next-line import/extensions
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
|
||||
let actualApp: null | typeof bundle.lib;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
// DEPRECATED: remove the next line in @actual-app/api v7
|
||||
export * as methods from './methods';
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateNodeVersion();
|
||||
|
||||
if (!globalThis.fetch) {
|
||||
globalThis.fetch = (url: URL | RequestInfo, init?: RequestInit) => {
|
||||
return import('node-fetch').then(({ default: fetch }) =>
|
||||
fetch(url as unknown as FetchInfo, init as unknown as FetchInit),
|
||||
) as unknown as Promise<Response>;
|
||||
};
|
||||
}
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
await actualApp.send('sync');
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
}
|
||||
}
|
||||
24
packages/api/jest.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: [
|
||||
'testing.js',
|
||||
'testing.ts',
|
||||
'api.js',
|
||||
'api.ts',
|
||||
'api.tsx',
|
||||
'electron.js',
|
||||
'electron.ts',
|
||||
'mjs',
|
||||
'js',
|
||||
'ts',
|
||||
'tsx',
|
||||
'json',
|
||||
],
|
||||
testEnvironment: 'node',
|
||||
testPathIgnorePatterns: ['/node_modules/'],
|
||||
watchPathIgnorePatterns: ['<rootDir>/mocks/budgets/'],
|
||||
setupFilesAfterEnv: ['<rootDir>/../loot-core/src/mocks/setup.ts'],
|
||||
transformIgnorePatterns: ['/node_modules/'],
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': '@swc/jest',
|
||||
},
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import * as injected from './injected';
|
||||
|
||||
export { default as q } from './app/query';
|
||||
|
||||
function send(name, args) {
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
} catch (e) {
|
||||
await send('api/abort-import');
|
||||
throw e;
|
||||
}
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
export async function downloadBudget(syncId, { password } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
export function addTransactions(accountId, transactions) {
|
||||
return send('api/transactions-add', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function importTransactions(accountId, transactions) {
|
||||
return send('api/transactions-import', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
export function filterTransactions(accountId, text) {
|
||||
return send('api/transactions-filter', { accountId, text });
|
||||
}
|
||||
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
export function createAccount(account, initialBalance) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
export function closeAccount(id, transferAccountId, transferCategoryId) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
transferCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategoryGroup(id, transferCategoryId) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategory(id, transferCategoryId) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
389
packages/api/methods.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
// @ts-strict-ignore
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
|
||||
beforeEach(async () => {
|
||||
// we need real datetime if we are going to mix new timestamps with our mock data
|
||||
global.restoreDateNow();
|
||||
|
||||
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
|
||||
await fs.rm(budgetPath, { force: true, recursive: true });
|
||||
|
||||
await createTestBudget('default-budget-template', budgetName);
|
||||
await api.init({
|
||||
dataDir: path.join(__dirname, '/mocks/budgets/'),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
global.currentMonth = null;
|
||||
await api.shutdown();
|
||||
});
|
||||
|
||||
async function createTestBudget(templateName: string, name: string) {
|
||||
const templatePath = path.join(
|
||||
__dirname,
|
||||
'/../loot-core/src/mocks/files',
|
||||
templateName,
|
||||
);
|
||||
const budgetPath = path.join(__dirname, '/mocks/budgets/', name);
|
||||
|
||||
await fs.mkdir(budgetPath);
|
||||
await fs.copyFile(
|
||||
path.join(templatePath, 'metadata.json'),
|
||||
path.join(budgetPath, 'metadata.json'),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(templatePath, 'db.sqlite'),
|
||||
path.join(budgetPath, 'db.sqlite'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('API setup and teardown', () => {
|
||||
// apis: loadBudget, getBudgetMonths
|
||||
test('successfully loads budget', async () => {
|
||||
await expect(api.loadBudget(budgetName)).resolves.toBeUndefined();
|
||||
|
||||
await expect(api.getBudgetMonths()).resolves.toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API CRUD operations', () => {
|
||||
beforeEach(async () => {
|
||||
// load test budget
|
||||
await api.loadBudget(budgetName);
|
||||
});
|
||||
|
||||
// apis: createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
|
||||
test('CategoryGroups: successfully update category groups', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// create our test category group
|
||||
const mainGroupId = await api.createCategoryGroup({
|
||||
name: 'test-group',
|
||||
});
|
||||
|
||||
let budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// update group
|
||||
await api.updateCategoryGroup(mainGroupId, {
|
||||
name: 'update-tests',
|
||||
});
|
||||
|
||||
budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// delete group
|
||||
await api.deleteCategoryGroup(mainGroupId);
|
||||
|
||||
budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createCategory, getCategories, updateCategory, deleteCategory
|
||||
test('Categories: successfully update categories', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// create our test category group
|
||||
const mainGroupId = await api.createCategoryGroup({
|
||||
name: 'test-group',
|
||||
});
|
||||
const secondaryGroupId = await api.createCategoryGroup({
|
||||
name: 'test-secondary-group',
|
||||
});
|
||||
const categoryId = await api.createCategory({
|
||||
name: 'test-budget',
|
||||
group_id: mainGroupId,
|
||||
});
|
||||
const categoryIdHidden = await api.createCategory({
|
||||
name: 'test-budget-hidden',
|
||||
group_id: mainGroupId,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
let categories = await api.getCategories();
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
name: 'test-budget',
|
||||
hidden: false,
|
||||
group_id: mainGroupId,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryIdHidden,
|
||||
name: 'test-budget-hidden',
|
||||
hidden: true,
|
||||
group_id: mainGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// update/move category
|
||||
await api.updateCategory(categoryId, {
|
||||
name: 'updated-budget',
|
||||
group_id: secondaryGroupId,
|
||||
});
|
||||
|
||||
await api.updateCategory(categoryIdHidden, {
|
||||
name: 'updated-budget-hidden',
|
||||
group_id: secondaryGroupId,
|
||||
hidden: false,
|
||||
});
|
||||
|
||||
categories = await api.getCategories();
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
name: 'updated-budget',
|
||||
hidden: false,
|
||||
group_id: secondaryGroupId,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: categoryIdHidden,
|
||||
name: 'updated-budget-hidden',
|
||||
hidden: false,
|
||||
group_id: secondaryGroupId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// delete categories
|
||||
await api.deleteCategory(categoryId);
|
||||
|
||||
expect(categories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
id: categoryId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: setBudgetAmount, setBudgetCarryover, getBudgetMonth
|
||||
test('Budgets: successfully update budgets', async () => {
|
||||
const month = '2023-10';
|
||||
global.currentMonth = month;
|
||||
|
||||
// create some new categories to test with
|
||||
const groupId = await api.createCategoryGroup({
|
||||
name: 'tests',
|
||||
});
|
||||
const categoryId = await api.createCategory({
|
||||
name: 'test-budget',
|
||||
group_id: groupId,
|
||||
});
|
||||
|
||||
await api.setBudgetAmount(month, categoryId, 100);
|
||||
await api.setBudgetCarryover(month, categoryId, true);
|
||||
|
||||
const budgetMonth = await api.getBudgetMonth(month);
|
||||
expect(budgetMonth.categoryGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: groupId,
|
||||
categories: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: categoryId,
|
||||
budgeted: 100,
|
||||
carryover: true,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount
|
||||
test('Accounts: successfully complete account operators', async () => {
|
||||
const accountId1 = await api.createAccount(
|
||||
{ name: 'test-account1', offbudget: true },
|
||||
1000,
|
||||
);
|
||||
const accountId2 = await api.createAccount({ name: 'test-account2' }, 0);
|
||||
let accounts = await api.getAccounts();
|
||||
|
||||
// accounts successfully created
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
offbudget: true,
|
||||
}),
|
||||
expect.objectContaining({ id: accountId2, name: 'test-account2' }),
|
||||
]),
|
||||
);
|
||||
|
||||
await api.updateAccount(accountId1, { offbudget: false });
|
||||
await api.closeAccount(accountId1, accountId2, null);
|
||||
await api.deleteAccount(accountId2);
|
||||
|
||||
// accounts successfully updated, and one of them deleted
|
||||
accounts = await api.getAccounts();
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
closed: true,
|
||||
offbudget: false,
|
||||
}),
|
||||
expect.not.objectContaining({ id: accountId2 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await api.reopenAccount(accountId1);
|
||||
|
||||
// the non-deleted account is reopened
|
||||
accounts = await api.getAccounts();
|
||||
expect(accounts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: accountId1,
|
||||
name: 'test-account1',
|
||||
closed: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createPayee, getPayees, updatePayee, deletePayee
|
||||
test('Payees: successfully update payees', async () => {
|
||||
const payeeId1 = await api.createPayee({ name: 'test-payee1' });
|
||||
const payeeId2 = await api.createPayee({ name: 'test-payee2' });
|
||||
let payees = await api.getPayees();
|
||||
|
||||
// payees successfully created
|
||||
expect(payees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: payeeId1,
|
||||
name: 'test-payee1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: payeeId2,
|
||||
name: 'test-payee2',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await api.updatePayee(payeeId1, { name: 'test-updated-payee' });
|
||||
await api.deletePayee(payeeId2);
|
||||
|
||||
// confirm update and delete were successful
|
||||
payees = await api.getPayees();
|
||||
expect(payees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: payeeId1,
|
||||
name: 'test-updated-payee',
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
name: 'test-payee1',
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
id: payeeId2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: addTransactions, getTransactions, importTransactions, updateTransaction, deleteTransaction
|
||||
test('Transactions: successfully update transactions', async () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
let newTransaction = [
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100 },
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100 },
|
||||
];
|
||||
|
||||
const addResult = await api.addTransactions(accountId, newTransaction, {
|
||||
learnCategories: true,
|
||||
runTransfers: true,
|
||||
});
|
||||
expect(addResult).toBe('ok');
|
||||
|
||||
// confirm added transactions exist
|
||||
let transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-11-01',
|
||||
'2023-11-30',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining(
|
||||
newTransaction.map(trans => expect.objectContaining(trans)),
|
||||
),
|
||||
);
|
||||
expect(transactions).toHaveLength(2);
|
||||
|
||||
newTransaction = [
|
||||
{ date: '2023-12-03', imported_id: '11', amount: 100 },
|
||||
{ date: '2023-12-03', imported_id: '22', amount: 200 },
|
||||
];
|
||||
|
||||
const reconciled = await api.importTransactions(accountId, newTransaction);
|
||||
|
||||
// Expect it to reconcile and to have updated one of the previous transactions
|
||||
expect(reconciled.added).toHaveLength(1);
|
||||
expect(reconciled.updated).toHaveLength(1);
|
||||
|
||||
// confirm imported transactions exist
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-12-01',
|
||||
'2023-12-31',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining(
|
||||
newTransaction.map(trans => expect.objectContaining(trans)),
|
||||
),
|
||||
);
|
||||
expect(transactions).toHaveLength(2);
|
||||
|
||||
const idToUpdate = reconciled.added[0];
|
||||
const idToDelete = reconciled.updated[0];
|
||||
await api.updateTransaction(idToUpdate, { amount: 500 });
|
||||
await api.deleteTransaction(idToDelete);
|
||||
|
||||
// confirm updates and deletions work
|
||||
transactions = await api.getTransactions(
|
||||
accountId,
|
||||
'2023-12-01',
|
||||
'2023-12-31',
|
||||
);
|
||||
expect(transactions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: idToUpdate, amount: 500 }),
|
||||
expect.not.objectContaining({ id: idToDelete }),
|
||||
]),
|
||||
);
|
||||
expect(transactions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
166
packages/api/methods.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// @ts-strict-ignore
|
||||
import type { Handlers } from 'loot-core/src/types/handlers';
|
||||
|
||||
import * as injected from './injected';
|
||||
|
||||
export { q } from './app/query';
|
||||
|
||||
function send<K extends keyof Handlers, T extends Handlers[K]>(
|
||||
name: K,
|
||||
args?: Parameters<T>[0],
|
||||
): Promise<Awaited<ReturnType<T>>> {
|
||||
return injected.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(name, func) {
|
||||
await send('api/start-import', { budgetName: name });
|
||||
try {
|
||||
await func();
|
||||
} catch (e) {
|
||||
await send('api/abort-import');
|
||||
throw e;
|
||||
}
|
||||
await send('api/finish-import');
|
||||
}
|
||||
|
||||
export async function loadBudget(budgetId) {
|
||||
return send('api/load-budget', { id: budgetId });
|
||||
}
|
||||
|
||||
export async function downloadBudget(syncId, { password }: { password? } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
|
||||
export async function batchBudgetUpdates(func) {
|
||||
await send('api/batch-budget-start');
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
await send('api/batch-budget-end');
|
||||
}
|
||||
}
|
||||
|
||||
export function runQuery(query) {
|
||||
return send('api/query', { query: query.serialize() });
|
||||
}
|
||||
|
||||
export function getBudgetMonths() {
|
||||
return send('api/budget-months');
|
||||
}
|
||||
|
||||
export function getBudgetMonth(month) {
|
||||
return send('api/budget-month', { month });
|
||||
}
|
||||
|
||||
export function setBudgetAmount(month, categoryId, value) {
|
||||
return send('api/budget-set-amount', { month, categoryId, amount: value });
|
||||
}
|
||||
|
||||
export function setBudgetCarryover(month, categoryId, flag) {
|
||||
return send('api/budget-set-carryover', { month, categoryId, flag });
|
||||
}
|
||||
|
||||
export function addTransactions(
|
||||
accountId,
|
||||
transactions,
|
||||
{ learnCategories = false, runTransfers = false } = {},
|
||||
) {
|
||||
return send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
learnCategories,
|
||||
runTransfers,
|
||||
});
|
||||
}
|
||||
|
||||
export function importTransactions(accountId, transactions) {
|
||||
return send('api/transactions-import', { accountId, transactions });
|
||||
}
|
||||
|
||||
export function getTransactions(accountId, startDate, endDate) {
|
||||
return send('api/transactions-get', { accountId, startDate, endDate });
|
||||
}
|
||||
|
||||
export function updateTransaction(id, fields) {
|
||||
return send('api/transaction-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTransaction(id) {
|
||||
return send('api/transaction-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccounts() {
|
||||
return send('api/accounts-get');
|
||||
}
|
||||
|
||||
export function createAccount(account, initialBalance?) {
|
||||
return send('api/account-create', { account, initialBalance });
|
||||
}
|
||||
|
||||
export function updateAccount(id, fields) {
|
||||
return send('api/account-update', { id, fields });
|
||||
}
|
||||
|
||||
export function closeAccount(id, transferAccountId?, transferCategoryId?) {
|
||||
return send('api/account-close', {
|
||||
id,
|
||||
transferAccountId,
|
||||
transferCategoryId,
|
||||
});
|
||||
}
|
||||
|
||||
export function reopenAccount(id) {
|
||||
return send('api/account-reopen', { id });
|
||||
}
|
||||
|
||||
export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group) {
|
||||
return send('api/category-group-create', { group });
|
||||
}
|
||||
|
||||
export function updateCategoryGroup(id, fields) {
|
||||
return send('api/category-group-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategoryGroup(id, transferCategoryId?) {
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
}
|
||||
|
||||
export function createCategory(category) {
|
||||
return send('api/category-create', { category });
|
||||
}
|
||||
|
||||
export function updateCategory(id, fields) {
|
||||
return send('api/category-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteCategory(id, transferCategoryId?) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
|
||||
export function createPayee(payee) {
|
||||
return send('api/payee-create', { payee });
|
||||
}
|
||||
|
||||
export function updatePayee(id, fields) {
|
||||
return send('api/payee-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
0
packages/api/mocks/budgets/.gitkeep
Normal file
@@ -1,27 +1,38 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "6.2.1",
|
||||
"version": "6.4.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "@types/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db"
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run build:app && jest -c jest.config.js",
|
||||
"clean": "rm -rf dist @types"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.6.0",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"compare-versions": "^6.1.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "es2021",
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node16",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/*"],
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist"]
|
||||
"exclude": ["**/node_modules/*", "dist", "@types"]
|
||||
}
|
||||
|
||||
16
packages/api/validateNodeVersion.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { satisfies } from 'compare-versions';
|
||||
|
||||
import * as packageJson from './package.json';
|
||||
|
||||
export function validateNodeVersion() {
|
||||
if (process?.versions?.node) {
|
||||
const nodeVersion = process?.versions?.node;
|
||||
const minimumNodeVersion = packageJson.engines.node;
|
||||
|
||||
if (!satisfies(nodeVersion, minimumNodeVersion)) {
|
||||
throw new Error(
|
||||
`@actual-app/api requires a node version ${minimumNodeVersion}. Found that you are using: ${nodeVersion}. Please upgrade to a higher version`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.3.82",
|
||||
"@swc/jest": "^0.2.29",
|
||||
"@swc/core": "^1.3.105",
|
||||
"@swc/jest": "^0.2.31",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.0.0",
|
||||
|
||||
@@ -69,8 +69,8 @@ describe('merkle trie', () => {
|
||||
});
|
||||
|
||||
test('diffing works with empty tries', () => {
|
||||
let trie1 = merkle.emptyTrie();
|
||||
let trie2 = merkle.insert(
|
||||
const trie1 = merkle.emptyTrie();
|
||||
const trie2 = merkle.insert(
|
||||
merkle.emptyTrie(),
|
||||
Timestamp.parse('2009-01-02T10:17:37.789Z-0000-0000testinguuid1')!,
|
||||
);
|
||||
@@ -79,7 +79,7 @@ describe('merkle trie', () => {
|
||||
});
|
||||
|
||||
test('pruning works and keeps correct hashes', () => {
|
||||
let messages = [
|
||||
const messages = [
|
||||
message('2018-11-01T01:00:00.000Z-0000-0123456789ABCDEF', 1000),
|
||||
message('2018-11-01T01:09:00.000Z-0000-0123456789ABCDEF', 1100),
|
||||
message('2018-11-01T01:18:00.000Z-0000-0123456789ABCDEF', 1200),
|
||||
@@ -101,13 +101,13 @@ describe('merkle trie', () => {
|
||||
expect(trie.hash).toBe(2496);
|
||||
expect(trie).toMatchSnapshot();
|
||||
|
||||
let pruned = merkle.prune(trie);
|
||||
const pruned = merkle.prune(trie);
|
||||
expect(pruned.hash).toBe(2496);
|
||||
expect(pruned).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('diffing differently shaped tries returns correct time', () => {
|
||||
let messages = [
|
||||
const messages = [
|
||||
message('2018-11-01T01:00:00.000Z-0000-0123456789ABCDEF', 1000),
|
||||
message('2018-11-01T01:09:00.000Z-0000-0123456789ABCDEF', 1100),
|
||||
message('2018-11-01T01:18:00.000Z-0000-0123456789ABCDEF', 1200),
|
||||
@@ -122,7 +122,7 @@ describe('merkle trie', () => {
|
||||
message('2018-11-01T02:37:00.000Z-0000-0123456789ABCDEF', 2100),
|
||||
];
|
||||
|
||||
let trie = insertMessages({}, messages);
|
||||
const trie = insertMessages({}, messages);
|
||||
|
||||
// Case 0: It always returns a base time when comparing with an
|
||||
// empty trie
|
||||
@@ -136,7 +136,7 @@ describe('merkle trie', () => {
|
||||
// Case 1: Add an older message that modifies the trie in such a
|
||||
// way that it modifies the 1st out of 3 branches (so it will be
|
||||
// pruned away)
|
||||
let trie1 = insertMessages(trie, [
|
||||
const trie1 = insertMessages(trie, [
|
||||
message('2018-11-01T00:59:00.000Z-0000-0123456789ABCDEF', 900),
|
||||
]);
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('merkle trie', () => {
|
||||
// Case 2: Add two messages similar to the above case, but the
|
||||
// second message modifies the 2nd key at the same level as the
|
||||
// first message modifying the 1st key
|
||||
let trie2 = insertMessages(trie, [
|
||||
const trie2 = insertMessages(trie, [
|
||||
message('2018-11-01T00:59:00.000Z-0000-0123456789ABCDEF', 900),
|
||||
message('2018-11-01T01:15:00.000Z-0000-0123456789ABCDEF', 1422),
|
||||
]);
|
||||
|
||||
@@ -36,7 +36,7 @@ export function getKeys(trie: TrieNode): NumberTrieNodeKey[] {
|
||||
export function keyToTimestamp(key: string): number {
|
||||
// 16 is the length of the base 3 value of the current time in
|
||||
// minutes. Ensure it's padded to create the full value
|
||||
let fullkey = key + '0'.repeat(16 - key.length);
|
||||
const fullkey = key + '0'.repeat(16 - key.length);
|
||||
|
||||
// Parse the base 3 representation
|
||||
return parseInt(fullkey, 3) * 1000 * 60;
|
||||
@@ -46,8 +46,8 @@ export function keyToTimestamp(key: string): number {
|
||||
* Mutates `trie` to insert a node at `timestamp`
|
||||
*/
|
||||
export function insert(trie: TrieNode, timestamp: Timestamp) {
|
||||
let hash = timestamp.hash();
|
||||
let key = Number(Math.floor(timestamp.millis() / 1000 / 60)).toString(3);
|
||||
const hash = timestamp.hash();
|
||||
const key = Number(Math.floor(timestamp.millis() / 1000 / 60)).toString(3);
|
||||
|
||||
trie = Object.assign({}, trie, { hash: (trie.hash || 0) ^ hash });
|
||||
return insertKey(trie, key, hash);
|
||||
@@ -68,8 +68,8 @@ function insertKey(trie: TrieNode, key: string, hash: number): TrieNode {
|
||||
}
|
||||
|
||||
export function build(timestamps: Timestamp[]) {
|
||||
let trie = emptyTrie();
|
||||
for (let timestamp of timestamps) {
|
||||
const trie = emptyTrie();
|
||||
for (const timestamp of timestamps) {
|
||||
insert(trie, timestamp);
|
||||
}
|
||||
return trie;
|
||||
@@ -89,11 +89,11 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
|
||||
// left (this shouldn't happen, if that's the case the hash check at
|
||||
// the top of this function should pass)
|
||||
while (1) {
|
||||
let keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
|
||||
let keys = [...keyset.values()];
|
||||
const keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
|
||||
const keys = [...keyset.values()];
|
||||
keys.sort();
|
||||
|
||||
let diffkey = null;
|
||||
let diffkey: null | '0' | '1' | '2' = null;
|
||||
|
||||
// Traverse down the trie through keys that aren't the same. We
|
||||
// traverse down the keys in order. Stop in two cases: either one
|
||||
@@ -110,10 +110,10 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
|
||||
// changed time that we know of, because of pruning it might take
|
||||
// multiple passes to sync up a trie.
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let key = keys[i];
|
||||
const key = keys[i];
|
||||
|
||||
let next1 = node1[key];
|
||||
let next2 = node2[key];
|
||||
const next1 = node1[key];
|
||||
const next2 = node2[key];
|
||||
|
||||
if (!next1 || !next2) {
|
||||
break;
|
||||
@@ -143,13 +143,13 @@ export function prune(trie: TrieNode, n = 2): TrieNode {
|
||||
return trie;
|
||||
}
|
||||
|
||||
let keys = getKeys(trie);
|
||||
const keys = getKeys(trie);
|
||||
keys.sort();
|
||||
|
||||
let next: TrieNode = { hash: trie.hash };
|
||||
const next: TrieNode = { hash: trie.hash };
|
||||
|
||||
// Prune child nodes.
|
||||
for (let k of keys.slice(-n)) {
|
||||
for (const k of keys.slice(-n)) {
|
||||
const node = trie[k];
|
||||
|
||||
if (!node) {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('Timestamp', function () {
|
||||
|
||||
describe('parsing', function () {
|
||||
it('should not parse', function () {
|
||||
let invalidInputs = [
|
||||
const invalidInputs = [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
@@ -44,19 +44,19 @@ describe('Timestamp', function () {
|
||||
'9999-12-31T23:59:59.999Z-10000-FFFFFFFFFFFFFFFF',
|
||||
'9999-12-31T23:59:59.999Z-FFFF-10000000000000000',
|
||||
];
|
||||
for (let invalidInput of invalidInputs) {
|
||||
for (const invalidInput of invalidInputs) {
|
||||
expect(Timestamp.parse(invalidInput as string)).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse', function () {
|
||||
let validInputs = [
|
||||
const validInputs = [
|
||||
'1970-01-01T00:00:00.000Z-0000-0000000000000000',
|
||||
'2015-04-24T22:23:42.123Z-1000-0123456789ABCDEF',
|
||||
'9999-12-31T23:59:59.999Z-FFFF-FFFFFFFFFFFFFFFF',
|
||||
];
|
||||
for (let validInput of validInputs) {
|
||||
let parsed = Timestamp.parse(validInput)!;
|
||||
for (const validInput of validInputs) {
|
||||
const parsed = Timestamp.parse(validInput)!;
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed.millis() >= 0).toBeTruthy();
|
||||
expect(parsed.millis() < 253402300800000).toBeTruthy();
|
||||
|
||||
@@ -80,7 +80,7 @@ export function makeClientId() {
|
||||
return uuidv4().replace(/-/g, '').slice(-16);
|
||||
}
|
||||
|
||||
let config = {
|
||||
const config = {
|
||||
// Allow 5 minutes of clock drift
|
||||
maxDrift: 5 * 60 * 1000,
|
||||
};
|
||||
@@ -96,9 +96,9 @@ export class Timestamp {
|
||||
|
||||
constructor(millis: number, counter: number, node: string) {
|
||||
this._state = {
|
||||
millis: millis,
|
||||
counter: counter,
|
||||
node: node,
|
||||
millis,
|
||||
counter,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,11 +168,11 @@ export class Timestamp {
|
||||
return timestamp;
|
||||
}
|
||||
if (typeof timestamp === 'string') {
|
||||
let parts = timestamp.split('-');
|
||||
const parts = timestamp.split('-');
|
||||
if (parts && parts.length === 5) {
|
||||
let millis = Date.parse(parts.slice(0, 3).join('-')).valueOf();
|
||||
let counter = parseInt(parts[3], 16);
|
||||
let node = parts[4];
|
||||
const millis = Date.parse(parts.slice(0, 3).join('-')).valueOf();
|
||||
const counter = parseInt(parts[3], 16);
|
||||
const node = parts[4];
|
||||
if (
|
||||
!isNaN(millis) &&
|
||||
millis >= 0 &&
|
||||
@@ -198,17 +198,17 @@ export class Timestamp {
|
||||
}
|
||||
|
||||
// retrieve the local wall time
|
||||
let phys = Date.now();
|
||||
const phys = Date.now();
|
||||
|
||||
// unpack the clock.timestamp logical time and counter
|
||||
let lOld = clock.timestamp.millis();
|
||||
let cOld = clock.timestamp.counter();
|
||||
const lOld = clock.timestamp.millis();
|
||||
const cOld = clock.timestamp.counter();
|
||||
|
||||
// calculate the next logical time and counter
|
||||
// * ensure that the logical time never goes backward
|
||||
// * increment the counter if phys time does not advance
|
||||
let lNew = Math.max(lOld, phys);
|
||||
let cNew = lOld === lNew ? cOld + 1 : 0;
|
||||
const lNew = Math.max(lOld, phys);
|
||||
const cNew = lOld === lNew ? cOld + 1 : 0;
|
||||
|
||||
// check the result for drift and counter overflow
|
||||
if (lNew - phys > config.maxDrift) {
|
||||
@@ -238,11 +238,11 @@ export class Timestamp {
|
||||
}
|
||||
|
||||
// retrieve the local wall time
|
||||
let phys = Date.now();
|
||||
const phys = Date.now();
|
||||
|
||||
// unpack the message wall time/counter
|
||||
let lMsg = msg.millis();
|
||||
let cMsg = msg.counter();
|
||||
const lMsg = msg.millis();
|
||||
const cMsg = msg.counter();
|
||||
|
||||
// assert the node id and remote clock drift
|
||||
// if (msg.node() === clock.timestamp.node()) {
|
||||
@@ -253,8 +253,8 @@ export class Timestamp {
|
||||
}
|
||||
|
||||
// unpack the clock.timestamp logical time and counter
|
||||
let lOld = clock.timestamp.millis();
|
||||
let cOld = clock.timestamp.counter();
|
||||
const lOld = clock.timestamp.millis();
|
||||
const cOld = clock.timestamp.counter();
|
||||
|
||||
// calculate the next logical time and counter
|
||||
// . ensure that the logical time never goes backward
|
||||
@@ -262,15 +262,15 @@ export class Timestamp {
|
||||
// . if max = old > message, increment local counter
|
||||
// . if max = messsage > old, increment message counter
|
||||
// . otherwise, clocks are monotonic, reset counter
|
||||
let lNew = Math.max(Math.max(lOld, phys), lMsg);
|
||||
let cNew =
|
||||
const lNew = Math.max(Math.max(lOld, phys), lMsg);
|
||||
const cNew =
|
||||
lNew === lOld && lNew === lMsg
|
||||
? Math.max(cOld, cMsg) + 1
|
||||
: lNew === lOld
|
||||
? cOld + 1
|
||||
: lNew === lMsg
|
||||
? cMsg + 1
|
||||
: 0;
|
||||
? cOld + 1
|
||||
: lNew === lMsg
|
||||
? cMsg + 1
|
||||
: 0;
|
||||
|
||||
// check the result for drift and counter overflow
|
||||
if (lNew - phys > config.maxDrift) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"compilerOptions": {
|
||||
// Using ES2021 because that’s the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "es2021",
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
|
||||
2
packages/desktop-client/.gitignore
vendored
@@ -10,11 +10,13 @@ test-results
|
||||
# production
|
||||
build
|
||||
build-stats
|
||||
stats.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env
|
||||
npm-debug.log
|
||||
.swc
|
||||
|
||||
*kcab.*
|
||||
public/kcab
|
||||
|
||||
@@ -32,25 +32,27 @@ Prerequisites:
|
||||
|
||||
#### Running against the local server
|
||||
|
||||
First start the dev server:
|
||||
First start a dev instance:
|
||||
|
||||
```sh
|
||||
HTTPS=true yarn start
|
||||
```
|
||||
Note the network IP address and port the dev instance is listening on.
|
||||
|
||||
Next, navigate to the root of your project folder, run the standartised docker container, and launch the visual regression tests from within it.
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.0-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
|
||||
|
||||
# If you recieve an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.37.0-jammy /bin/bash
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash
|
||||
|
||||
# Run the VRT tests: important - they MUST be ran against a HTTPS server
|
||||
E2E_START_URL=https://192.168.0.178:3001 yarn vrt
|
||||
# Run the VRT tests: important - they MUST be ran against a HTTPS server. Use the ip and port noted earlier
|
||||
E2E_START_URL=https://ip:port yarn vrt
|
||||
|
||||
# To update snapshots, use the following command:
|
||||
E2E_START_URL=https://192.168.0.178:3001 yarn vrt --update-snapshots
|
||||
E2E_START_URL=https://ip:port yarn vrt --update-snapshots
|
||||
```
|
||||
|
||||
#### Running against a remote server
|
||||
|
||||
@@ -8,7 +8,6 @@ echo "Building the browser..."
|
||||
rm -fr build
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export INLINE_RUNTIME_CHUNK=false
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
|
||||
yarn build
|
||||
@@ -16,4 +15,4 @@ yarn build
|
||||
rm -fr build-stats
|
||||
mkdir build-stats
|
||||
mv build/kcab/stats.json build-stats/loot-core-stats.json
|
||||
mv build/stats.json build-stats/desktop-client-stats.json
|
||||
mv ./stats.json build-stats/web-stats.json
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
loaderByName,
|
||||
removeLoaders,
|
||||
addAfterLoader,
|
||||
addPlugins,
|
||||
} = require('@craco/craco');
|
||||
const chokidar = require('chokidar');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const { IgnorePlugin } = require('webpack');
|
||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||
|
||||
if (process.env.CI) {
|
||||
process.env.DISABLE_ESLINT_PLUGIN = 'true';
|
||||
}
|
||||
|
||||
// Forward Netlify env variables
|
||||
if (process.env.REVIEW_ID) {
|
||||
process.env.REACT_APP_REVIEW_ID = process.env.REVIEW_ID;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
configure: (webpackConfig, { env, paths }) => {
|
||||
webpackConfig.mode =
|
||||
process.env.NODE_ENV === 'development' ? 'development' : 'production';
|
||||
|
||||
// swc-loader
|
||||
addAfterLoader(webpackConfig, loaderByName('babel-loader'), {
|
||||
test: /\.m?[tj]sx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: require.resolve('swc-loader'),
|
||||
});
|
||||
|
||||
// remove the babel loaders
|
||||
removeLoaders(webpackConfig, loaderByName('babel-loader'));
|
||||
|
||||
addPlugins(webpackConfig, [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'disabled',
|
||||
generateStatsFile: true,
|
||||
}),
|
||||
// Pikaday throws a warning if Moment.js is not installed however it doesn't
|
||||
// actually require it to be installed. As we don't use Moment.js ourselves
|
||||
// then we can just silence this warning.
|
||||
new IgnorePlugin({
|
||||
contextRegExp: /pikaday$/,
|
||||
resourceRegExp: /moment$/,
|
||||
}),
|
||||
]);
|
||||
|
||||
webpackConfig.resolve.extensions = [
|
||||
'.web.js',
|
||||
'.web.jsx',
|
||||
'.web.ts',
|
||||
'.web.tsx',
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
...webpackConfig.resolve.extensions,
|
||||
];
|
||||
|
||||
if (process.env.IS_GENERIC_BROWSER) {
|
||||
webpackConfig.resolve.extensions = [
|
||||
'.browser.js',
|
||||
'.browser.jsx',
|
||||
'.browser.ts',
|
||||
'.browser.tsx',
|
||||
...webpackConfig.resolve.extensions,
|
||||
];
|
||||
}
|
||||
|
||||
webpackConfig.optimization = {
|
||||
...webpackConfig.optimization,
|
||||
minimize:
|
||||
process.env.CI === 'true' || process.env.NODE_ENV !== 'development',
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
minify: TerserPlugin.swcMinify,
|
||||
// `terserOptions` options will be passed to `swc` (`@swc/core`)
|
||||
// Link to options - https://swc.rs/docs/config-js-minify
|
||||
terserOptions: {
|
||||
compress: false,
|
||||
mangle: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
return webpackConfig;
|
||||
},
|
||||
},
|
||||
devServer: (devServerConfig, { env, paths, proxy, allowedHost }) => {
|
||||
devServerConfig.onBeforeSetupMiddleware = server => {
|
||||
chokidar
|
||||
.watch([
|
||||
path.resolve('../loot-core/lib-dist/*.js'),
|
||||
path.resolve('../loot-core/lib-dist/browser/*.js'),
|
||||
])
|
||||
.on('all', function () {
|
||||
for (const ws of server.webSocketServer.clients) {
|
||||
ws.send(JSON.stringify({ type: 'static-changed' }));
|
||||
}
|
||||
});
|
||||
};
|
||||
devServerConfig.headers = {
|
||||
...devServerConfig.headers,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
};
|
||||
|
||||
return devServerConfig;
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Accounts', () => {
|
||||
let page;
|
||||
@@ -35,7 +34,7 @@ test.describe('Accounts', () => {
|
||||
await expect(transaction.category).toHaveText('Starting Balances');
|
||||
await expect(transaction.debit).toHaveText('');
|
||||
await expect(transaction.credit).toHaveText('100.00');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('closes an account', async () => {
|
||||
@@ -45,10 +44,10 @@ test.describe('Accounts', () => {
|
||||
|
||||
const modal = await accountPage.clickCloseAccount();
|
||||
await modal.selectTransferAccount('Vanguard 401k');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
await modal.closeAccount();
|
||||
|
||||
await expect(accountPage.accountName).toHaveText('Closed: Roth IRA');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -1,7 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Budget', () => {
|
||||
let page;
|
||||
@@ -34,7 +33,7 @@ test.describe('Budget', () => {
|
||||
await expect(summary.getByText(/^Overspent in /)).toBeVisible();
|
||||
await expect(summary.getByText('Budgeted')).toBeVisible();
|
||||
await expect(summary.getByText('For Next Month')).toBeVisible();
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('transfer funds to another category', async () => {
|
||||
@@ -47,7 +46,7 @@ test.describe('Budget', () => {
|
||||
expect(await budgetPage.getBalanceForRow(2)).toEqual(
|
||||
currentFundsA + currentFundsB,
|
||||
);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('budget table is rendered', async () => {
|
||||
@@ -60,8 +59,8 @@ test.describe('Budget', () => {
|
||||
});
|
||||
|
||||
test('clicking on spent amounts opens a transaction page', async () => {
|
||||
let categoryName = await budgetPage.getCategoryNameForRow(1);
|
||||
let accountPage = await budgetPage.clickOnSpentAmountForRow(1);
|
||||
const categoryName = await budgetPage.getCategoryNameForRow(1);
|
||||
const accountPage = await budgetPage.clickOnSpentAmountForRow(1);
|
||||
expect(page.url()).toContain('/accounts');
|
||||
expect(await accountPage.accountName.textContent()).toMatch(
|
||||
new RegExp(String.raw`${categoryName} \(\w+ \d+\)`),
|
||||
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -1671,9 +1671,70 @@
|
||||
"import_payee_name_original": null,
|
||||
"debt_transaction_type": null,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"id": "213526fc-ba49-4790-8a96-cc2a50182728",
|
||||
"date": "2023-09-04",
|
||||
"amount": -100000,
|
||||
"memo": "Test transaction",
|
||||
"cleared": "cleared",
|
||||
"approved": true,
|
||||
"flag_color": null,
|
||||
"account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",
|
||||
"payee_id": "2a20470a-634f-4efa-a7f6-f1c0b0bdda41",
|
||||
"category_id": "36120d44-6c61-4402-985a-891a8d267858",
|
||||
"transfer_account_id": null,
|
||||
"transfer_transaction_id": null,
|
||||
"matched_transaction_id": null,
|
||||
"import_id": null,
|
||||
"import_payee_name": null,
|
||||
"import_payee_name_original": null,
|
||||
"debt_transaction_type": null,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"id": "024494a1-f1e0-4667-9fc0-91e4a4262193",
|
||||
"date": "2023-09-04",
|
||||
"amount": 50000,
|
||||
"memo": "split part b",
|
||||
"cleared": "cleared",
|
||||
"approved": true,
|
||||
"flag_color": null,
|
||||
"account_id": "125f339b-2a63-481e-84c0-f04d898905d2",
|
||||
"payee_id": "",
|
||||
"category_id": null,
|
||||
"transfer_account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32",
|
||||
"transfer_transaction_id": "213526fc-ba49-4790-8a96-cc2a50182728",
|
||||
"matched_transaction_id": "",
|
||||
"import_id": null,
|
||||
"import_payee_name": null,
|
||||
"import_payee_name_original": null,
|
||||
"debt_transaction_type": null,
|
||||
"deleted": false
|
||||
}
|
||||
],
|
||||
"subtransactions": [
|
||||
{
|
||||
"id": "d8ec8c84-5033-4f7e-8485-66bfe19a70d6",
|
||||
"transaction_id": "213526fc-ba49-4790-8a96-cc2a50182728",
|
||||
"amount": -50000,
|
||||
"memo": "split part a",
|
||||
"payee_id": "2a20470a-634f-4efa-a7f6-f1c0b0bdda41",
|
||||
"category_id": "36120d44-6c61-4402-985a-891a8d267858",
|
||||
"transfer_account_id": null,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"id": "870d8780-79cf-4197-a341-47d24b2b5a59",
|
||||
"transaction_id": "213526fc-ba49-4790-8a96-cc2a50182728",
|
||||
"amount": -50000,
|
||||
"memo": "split part b",
|
||||
"payee_id": "2a20470a-634f-4efa-a7f6-f1c0b0bdda41",
|
||||
"category_id": null,
|
||||
"transfer_account_id": "125f339b-2a63-481e-84c0-f04d898905d2",
|
||||
"deleted": false
|
||||
}
|
||||
],
|
||||
"subtransactions": [],
|
||||
"scheduled_transactions": [],
|
||||
"scheduled_subtransactions": []
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { MobileNavigation } from './page-models/mobile-navigation';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Mobile', () => {
|
||||
let page;
|
||||
@@ -44,46 +43,48 @@ test.describe('Mobile', () => {
|
||||
'Water',
|
||||
'Power',
|
||||
]);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('opens the accounts page and asserts on balances', async () => {
|
||||
const accountsPage = await navigation.goToAccountsPage();
|
||||
|
||||
const account = await accountsPage.getNthAccount(0);
|
||||
const account = await accountsPage.getNthAccount(1);
|
||||
|
||||
await expect(account.name).toHaveText('Ally Savings');
|
||||
await expect(account.balance).toHaveText('7,653.00');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('opens individual account page and checks that filtering is working', async () => {
|
||||
const accountsPage = await navigation.goToAccountsPage();
|
||||
const accountPage = await accountsPage.openNthAccount(1);
|
||||
const accountPage = await accountsPage.openNthAccount(0);
|
||||
|
||||
await expect(accountPage.heading).toHaveText('Bank of America');
|
||||
expect(await accountPage.getBalance()).toBeGreaterThan(0);
|
||||
|
||||
await expect(accountPage.noTransactionsFoundError).not.toBeVisible();
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await accountPage.searchByText('nothing should be found');
|
||||
await expect(accountPage.noTransactionsFoundError).toBeVisible();
|
||||
await expect(accountPage.transactions).toHaveCount(0);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await accountPage.searchByText('Kroger');
|
||||
await expect(accountPage.transactions).not.toHaveCount(0);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a transaction via footer button', async () => {
|
||||
const transactionEntryPage = await navigation.goToTransactionEntryPage();
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await expect(transactionEntryPage.header).toHaveText('New Transaction');
|
||||
|
||||
await transactionEntryPage.amountField.fill('12.34');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('payee-field'),
|
||||
'Kroger',
|
||||
@@ -96,14 +97,14 @@ test.describe('Mobile', () => {
|
||||
page.getByTestId('account-field'),
|
||||
'Ally Savings',
|
||||
);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
const accountPage = await transactionEntryPage.createTransaction();
|
||||
|
||||
await expect(accountPage.transactions.nth(0)).toHaveText(
|
||||
'KrogerClothing-12.34',
|
||||
);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a transaction from `/accounts/:id` page', async () => {
|
||||
@@ -112,9 +113,11 @@ test.describe('Mobile', () => {
|
||||
const transactionEntryPage = await accountPage.clickCreateTransaction();
|
||||
|
||||
await expect(transactionEntryPage.header).toHaveText('New Transaction');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await transactionEntryPage.amountField.fill('12.34');
|
||||
// Click anywhere to cancel active edit.
|
||||
await transactionEntryPage.header.click();
|
||||
await transactionEntryPage.fillField(
|
||||
page.getByTestId('payee-field'),
|
||||
'Kroger',
|
||||
@@ -133,7 +136,7 @@ test.describe('Mobile', () => {
|
||||
|
||||
test('checks that settings page can be opened', async () => {
|
||||
const settingsPage = await navigation.goToSettingsPage();
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
@@ -144,6 +147,6 @@ test.describe('Mobile', () => {
|
||||
expect(await download.suggestedFilename()).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}-.*.zip$/,
|
||||
);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -5,7 +5,6 @@ import { test, expect } from '@playwright/test';
|
||||
import { AccountPage } from './page-models/account-page';
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Onboarding', () => {
|
||||
let page;
|
||||
@@ -26,10 +25,10 @@ test.describe('Onboarding', () => {
|
||||
|
||||
test('checks the page visuals', async () => {
|
||||
await expect(configurationPage.heading).toHaveText('Where’s the server?');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await configurationPage.clickOnNoServer();
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a new budget file by importing YNAB4 budget', async () => {
|
||||
@@ -60,10 +59,10 @@ test.describe('Onboarding', () => {
|
||||
await expect(budgetPage.budgetTable).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const accountPage = await navigation.goToAccountPage('Checking');
|
||||
await expect(accountPage.accountBalance).toHaveText('700.00');
|
||||
await expect(accountPage.accountBalance).toHaveText('600.00');
|
||||
|
||||
await navigation.goToAccountPage('Saving');
|
||||
await expect(accountPage.accountBalance).toHaveText('200.00');
|
||||
await expect(accountPage.accountBalance).toHaveText('250.00');
|
||||
});
|
||||
|
||||
test('creates a new budget file by importing Actual budget', async () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 577 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 649 KiB |
@@ -30,9 +30,17 @@ export class MobileNavigation {
|
||||
}
|
||||
|
||||
async goToSettingsPage() {
|
||||
await this.dragNavbarUp();
|
||||
|
||||
const link = this.page.getByRole('link', { name: 'Settings' });
|
||||
await link.click();
|
||||
|
||||
return new SettingsPage(this.page);
|
||||
}
|
||||
|
||||
async dragNavbarUp() {
|
||||
await this.page
|
||||
.getByRole('navigation')
|
||||
.dragTo(this.page.getByTestId('budget-table'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ export class ReportsPage {
|
||||
return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor();
|
||||
}
|
||||
|
||||
async goToNetWorthPage() {
|
||||
await this.pageContent.getByRole('link', { name: /^Net/ }).click();
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async goToCashFlowPage() {
|
||||
await this.pageContent.getByRole('link', { name: /^Cash/ }).click();
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async getAvailableReportList() {
|
||||
return this.pageContent
|
||||
.getByRole('link')
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Reports', () => {
|
||||
let page;
|
||||
@@ -32,6 +31,16 @@ test.describe('Reports', () => {
|
||||
const reports = await reportsPage.getAvailableReportList();
|
||||
|
||||
expect(reports).toEqual(['Net Worth', 'Cash Flow']);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('loads net worth graph and checks visuals', async () => {
|
||||
await reportsPage.goToNetWorthPage();
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('loads cash flow graph and checks visuals', async () => {
|
||||
await reportsPage.goToCashFlowPage();
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
import screenshotConfig from './screenshot.config';
|
||||
|
||||
test.describe('Rules', () => {
|
||||
let page;
|
||||
@@ -29,7 +28,7 @@ test.describe('Rules', () => {
|
||||
|
||||
test('checks the page visuals', async () => {
|
||||
await rulesPage.searchFor('Dominion');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
|
||||
@@ -53,7 +52,7 @@ test.describe('Rules', () => {
|
||||
const rule = rulesPage.getNthRule(0);
|
||||
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
|
||||
await expect(rule.actions).toHaveText(['set category to General']);
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
const accountPage = await navigation.goToAccountPage('HSBC');
|
||||
|
||||
@@ -66,6 +65,6 @@ test.describe('Rules', () => {
|
||||
await expect(transaction.payee).toHaveText('Fast Internet');
|
||||
await expect(transaction.category).toHaveText('General');
|
||||
await expect(transaction.debit).toHaveText('12.34');
|
||||
await expect(page).toHaveScreenshot(screenshotConfig(page));
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |