Compare commits

..

3 Commits

Author SHA1 Message Date
Matt Fiddaman
380f83f3ee recharts differences 2025-10-22 17:10:42 +01:00
Matt Fiddaman
364110ae65 note 2025-10-22 14:26:52 +01:00
Matt Fiddaman
aa1f59e532 bump dependencies 2025-10-22 14:26:31 +01:00
1210 changed files with 6477 additions and 45348 deletions

View File

@@ -1,69 +0,0 @@
name: 'Documentation'
description: Report documentation issues, request new documentation, or suggest improvements to existing docs.
title: '[Docs] - <title>'
labels: ['documentation']
body:
- type: dropdown
id: issue-type
attributes:
label: 'Issue Type'
description: What type of documentation issue is this?
options:
- New Documentation Request
- Documentation Improvement
- Documentation Bug/Error
- Documentation Change Request
validations:
required: true
- type: textarea
id: description
attributes:
label: 'Description'
description: Please describe the documentation issue, request, or improvement
placeholder: Provide a clear and detailed description...
validations:
required: true
- type: input
id: doc-url
attributes:
label: 'Documentation URL'
description: If this relates to existing documentation, please provide the URL
placeholder: ex. https://actualbudget.org/docs/budgeting/categories or https://github.com/actualbudget/actual/blob/master/packages/docs/...
validations:
required: false
- type: dropdown
id: category
attributes:
label: 'Documentation Category'
description: What category does this relate to?
multiple: true
options:
- Accounts
- Backup & Restore
- Budgeting
- Development
- Installation & Configuration
- Overview
- Reports
- Troubleshooting
- Other
validations:
required: false
- type: textarea
id: expected-behavior
attributes:
label: 'Expected/Desired Content'
description: If applicable, describe what you expect to see or what should be documented
placeholder: What should the documentation say or include?
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: 'Screenshots or Examples'
description: If applicable, add screenshots or examples to help explain your request
value: |
![DESCRIPTION](LINK.png)
render: bash
validations:
required: false

View File

@@ -1,17 +0,0 @@
# check-spelling/check-spelling configuration
| File | Purpose | Format | Info |
| -------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [dictionary.txt](dictionary.txt) | Replacement dictionary (creating this file will override the default dictionary) | one word per line | [dictionary](https://github.com/check-spelling/check-spelling/wiki/Configuration#dictionary) |
| [allow.txt](allow.txt) | Add words to the dictionary | one word per line (only letters and `'`s allowed) | [allow](https://github.com/check-spelling/check-spelling/wiki/Configuration#allow) |
| [reject.txt](reject.txt) | Remove words from the dictionary (after allow) | grep pattern matching whole dictionary words | [reject](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-reject) |
| [excludes.txt](excludes.txt) | Files to ignore entirely | perl regular expression | [excludes](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-excludes) |
| [only.txt](only.txt) | Only check matching files (applied after excludes) | perl regular expression | [only](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-only) |
| [patterns.txt](patterns.txt) | Patterns to ignore from checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
| [candidate.patterns](candidate.patterns) | Patterns that might be worth adding to [patterns.txt](patterns.txt) | perl regular expression with optional comment block introductions (all matches will be suggested) | [candidates](https://github.com/check-spelling/check-spelling/wiki/Feature:-Suggest-patterns) |
| [line_forbidden.patterns](line_forbidden.patterns) | Patterns to flag in checked lines | perl regular expression (order matters, first match wins) | [patterns](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-patterns) |
| [expect.txt](expect.txt) | Expected words that aren't in the dictionary | one word per line (sorted, alphabetically) | [expect](https://github.com/check-spelling/check-spelling/wiki/Configuration#expect) |
| [advice.md](advice.md) | Supplement for GitHub comment when unrecognized words are found | GitHub Markdown | [advice](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice) |
Note: you can replace any of these files with a directory by the same name (minus the suffix)
and then include multiple files inside that directory (with that suffix) to merge multiple files together.

View File

@@ -1,24 +0,0 @@
<!-- See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples%3A-advice --> <!-- markdownlint-disable MD033 MD041 -->
<details>
<summary>If the flagged items are :exploding_head: false positives</summary>
If items relate to a ...
- binary file (or some other file you wouldn't want to check at all).
Please add a file path to the `excludes.txt` file matching the containing file.
File paths are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your files.
`^` refers to the file's path from the root of the repository, so `^README\.md$` would exclude [README.md](../tree/HEAD/README.md) (on whichever branch you're using).
- well-formed pattern.
If you can write a [pattern](https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns) that would match it,
try adding it to the `patterns.txt` file.
Patterns are Perl 5 Regular Expressions - you can [test](https://www.regexplanet.com/advanced/perl/) yours before committing to verify it will match your lines.
Note that patterns can't match multiline strings.
</details>

View File

@@ -1,8 +0,0 @@
trevdor
Farlow
Matiss
Aboltins
jlongster
howell
evequefou
Fiddaman

View File

@@ -1,144 +0,0 @@
ABANCA
actualbudget
addtransactions
Akahu
AMZN
Andelskassen
AQL
Authelia
autocompletes
Blix
bnp
BSCHESMM
BTC
CAGLESMM
Caju
caniuse
Cardless
CAROOT
categorygroup
Cembra
Certbot
CLI
clickable
clsx
codemirror
Coinbase
commandlet
Coverflex
Crd
crdt
creditcards
crowdsourced
debian
dedupes
deleteaccount
DKB
dmg
easybank
Edenred
Coverfelx
emojis
emoji
escodegen
EUR
expando
Firefox
flyctl
Formik
Fortuneo
gebabebb
GEBABEBB
Greenshot
HSA
htpasswd
IBANs
iex
importtransactions
ING
invokable
iwr
jointaccounts
jwl
KBC
kcab
keyout
KREDBEBB
Kroger
kubectl
kubernetes
ldaplogin
letsencrypt
libofx
linting
Linuxes
linuxsvg
lleskassen
lte
mac
macsvg
Mariushosting
minimalistic
monkeypatch
Monobank
Morrisons
NAIAGB
NDEADKKK
Netflix
netlify
Nordea
NORDEA
nordigen
notlike
NRNBGB
nynab
offbudget
ofx
OFX
oneof
payeerule
pikaday
pikapods
playsinline
portalization
Postgresql
protobuf
publix
QFX
QIF
Quicken
returnsandreimbursements
Rezip
roadmap
RUpdate
sankey
SANTANDER
screenshots
SEB
subfolders
subreaper
subtransaction
subtransactions
Suisse
Sztup
tini
traefik
Trafico
Trumf
Upstash
useb
usernames
valign
Venmo
Weblate
winsvg
WSL
Xxxxx
ynab
Ynab
YNAB
ZKB
Zsolt
IDBy
isapprox
isbetween

View File

@@ -1,76 +0,0 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes
(?:^|/)(?i)COPYRIGHT
(?:^|/)(?i)LICEN[CS]E
(?:^|/)3rdparty/
(?:^|/)go\.sum$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)pyproject.toml
(?:^|/)requirements(?:-dev|-doc|-test|)\.txt$
(?:^|/)vendor/
ignore$
\.a$
\.ai$
\.avi$
\.bmp$
\.bz2$
\.class$
\.coveragerc$
\.crt$
\.css$
\.dll$
\.docx?$
\.drawio$
\.DS_Store$
\.eot$
\.exe$
\.gif$
\.git-blame-ignore-revs$
\.gitattributes$
\.graffle$
\.gz$
\.icns$
\.ico$
\.jar$
\.jks$
\.jpe?g$
\.key$
\.lib$
\.lock$
\.map$
\.min\..
\.mod$
\.mp[34]$
\.o$
\.ocf$
\.otf$
\.pdf$
\.pem$
\.png$
\.psd$
\.pyc$
\.pylintrc$
\.s$
\.svgz?$
\.tar$
\.tiff?$
\.ttf$
\.wav$
\.webm$
\.webp$
\.woff2?$
\.xlsx?$
\.zip$
^\.github/actions/spelling/
^\.github/ISSUE_TEMPLATE/
^\Q.github/workflows/spelling.yml\E$
^\.yarn/
^\Qnode_modules/\E$
^\Qsrc/\E$
^\Qstatic/\E$
^\Q.github/\E$
(?:^|/)package(?:-lock|)\.json$
(?:^|/)yarn\.lock$
(?:^|/)(?i)docusaurus.config.js
(?:^|/)(?i)README.md
^\static/
\.tsx$

View File

@@ -1,152 +0,0 @@
Abanca
ABNAMRO
ABNANL
Activo
AESUDEF
ALZEY
Anglais
aql
AUR
Authentik
BANKA
BANKINTER
BAWAATWW
Belfius
Biedenkopf
BIGBPLPW
Bizum
BKBKESMM
BOFIIE
Bourso
Boursobank
Boursorama
BPER
BPMOIT
brexplpw
BYLADEM
Caddyfile
CAGLPTPL
Caixa
CAMT
cashflow
Cetelem
cimode
Citi
Citibank
Cloudflare
CMCIFRPAXXX
COBADEFF
CODEOWNERS
commerzbank
Copiar
CREGBEBB
crt
Danske
datadir
Depositos
DIREKT
Dockerfiles
Dominguez
DUSSDEDDXXX
DUSSELDORF
ENTERCARD
Entra
EUA
Eurocard
fidd
Fineco
Finicity
Fintro
Finverse
Flathub
FORTUNEO
FTNOFRP
Gemeinschaftsbank
Geral
gernes
Globecard
GLS
gocardless
Grafana
HABAL
Hampel
HELADEF
INDUSTRIEL
INGBPLPW
Ingo
INR
Intesa
INVSTMTMSGSRS
ISYBANK
ITBBITMM
jfdoming
JMD
KBCBE
Keycloak
Khurozov
KORT
Kreditbank
lage
LHV
LHVBEE
LKR
mbank
mdc
modals
Moldovan
murmurhash
nginx
OIDC
overbudgeted
overbudgeting
Paribas
passwordless
pluggyai
Poste
PPABPLPK
prefs
Primoco
Priotecs
proactively
pwa
Qatari
QNTOFRP
QONTO
Raiffeisen
revolut
RIED
RSchedule
RSD
SEK
simplefin
SKHSFI
Sparkasse
SPK
sseldorf
SSK
Stadtsparkasse
statestore
SUBASKBX
SVGR
swc
SWEDBANK
SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB
touchscreen
triaging
UAH
ubuntu
userinfo
Userscripts
UZS
vrt
VUB
websecure
Widiba
WOR
youngcw

View File

@@ -1,62 +0,0 @@
# reject `m_data` as there's a certain OS which has evil defines that break things if it's used elsewhere
# \bm_data\b
# If you have a framework that uses `it()` for testing and `fit()` for debugging a specific test,
# you might not want to check in code where you were debugging w/ `fit()`, in which case, you might want
# to use this:
#\bfit\(
# s.b. GitHub
#\bGithub\b
# s.b. GitLab
\bGitlab\b
# s.b. JavaScript
\bJavascript\b
# s.b. Microsoft
\bMicroSoft\b
# s.b. another
\ban[- ]other\b
# s.b. greater than
\bgreater then\b
# s.b. into
#\sin to\s
# s.b. opt-in
\sopt in\s
# s.b. less than
\bless then\b
# s.b. otherwise
\bother[- ]wise\b
# s.b. nonexistent
\bnon existing\b
\b[Nn]o[nt][- ]existent\b
# s.b. preexisting
[Pp]re[- ]existing
# s.b. preempt
[Pp]re[- ]empt\b
# s.b. preemptively
[Pp]re[- ]emptively
# s.b. reentrancy
[Rr]e[- ]entrancy
# s.b. reentrant
[Rr]e[- ]entrant
# s.b. workaround(s)
\bwork[- ]arounds?\b
# Reject duplicate words
\s([A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})\s\g{-1}\s

View File

@@ -1,3 +0,0 @@
# Only check files in the packages/docs directory
^packages/docs/

View File

@@ -1,81 +0,0 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
# Questionably acceptable forms of `in to`
# Personally, I prefer `log into`, but people object
# https://www.tprteaching.com/log-into-log-in-to-login/
\b[Ll]og in to\b
# acceptable duplicates
# ls directory listings
[-bcdlpsw](?:[-r][-w][-Ssx]){3}\s+\d+\s+\S+\s+\S+\s+\d+\s+
# C types and repeated CSS values
\s(center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \g{-1})+\s
# go templates
\s(\w+)\s+\g{-1}\s+\`(?:graphql|json|yaml):
# javadoc / .net
(?:[\\@](?:groupname|param)|(?:public|private)(?:\s+static|\s+readonly)*)\s+(\w+)\s+\g{-1}\s
# Commit message -- Signed-off-by and friends
^\s*(?:(?:Based-on-patch|Co-authored|Helped|Mentored|Reported|Reviewed|Signed-off)-by|Thanks-to): (?:[^<]*<[^>]*>|[^<]*)\s*$
# Autogenerated revert commit message
^This reverts commit [0-9a-f]{40}\.$
# ignore long runs of a single character:
\b([A-Za-z])\g{-1}{3,}\b
# Automatically suggested patterns
# hit-count: 1255 file-count: 51
# https/http/file urls
(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]
# hit-count: 1174 file-count: 33
# GitHub SHAs (markdown)
(?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|)
# hit-count: 6 file-count: 4
# version suffix <word>v#
(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_]))
# hit-count: 6 file-count: 2
# URL escaped characters
\%[0-9A-F][A-F]
# hit-count: 5 file-count: 4
# hex runs
\b[0-9a-fA-F]{16,}\b
# hit-count: 4 file-count: 2
# uuid:
\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b
# hit-count: 3 file-count: 2
# discord
/discord(?:app\.com|\.gg)/(?:invite/)?[a-zA-Z0-9]{7,}
# hit-count: 2 file-count: 2
# Contributor
\[[^\]]+\]\(https://github\.com/[^/\s"]+\)
@[^$\W]*-?\w+
# hit-count: 1 file-count: 1
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]*
# hit-count: 1 file-count: 1
# Google Fonts
\bfonts\.(?:googleapis|gstatic)\.com/[-/?=:;+&0-9a-zA-Z]*
# hit-count: 1 file-count: 1
# hex digits including css/html color classes:
(?:[\\0][xX]|\\u|[uU]\+|#x?|\%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|u\d+)\b
# docusaurus image paths, URLs
[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?
# eliminate words like [`nvm`] or [`asdf`] or [heidiSQL] without backquotes
\[.+?]
# allowlist specific non-English words with non-ASCII characters
\b(Länsförsäkringar|München|Złoty)\b

View File

@@ -1,10 +0,0 @@
^attache$
benefitting
occurences?
^dependan.*
^oer$
Sorce
^[Ss]pae.*
^untill$
^untilling$
^wether.*

View File

@@ -15,7 +15,7 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install yarn
@@ -27,7 +27,7 @@ runs:
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
@@ -36,7 +36,7 @@ runs:
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
shell: bash
- name: Cache Lage
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@v4
with:
path: ${{ format('{0}/.lage', inputs.working-directory) }}
key: lage-${{ runner.os }}-${{ github.sha }}
@@ -48,7 +48,7 @@ runs:
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

@@ -4,32 +4,43 @@ import pLimit from 'p-limit';
const limit = pLimit(50);
const CONFIG = {
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
// Point tiers for code changes (non-docs)
CODE_PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
/** Repository-specific configuration for points calculation */
const REPOSITORY_CONFIG = new Map([
[
'actual',
{
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 0,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
],
EXCLUDED_FILES: [
'yarn.lock',
'.yarn/**/*',
'packages/component-library/src/icons/**/*',
'release-notes/**/*',
],
},
],
// Point tiers for docs changes (packages/docs/**)
DOCS_PR_REVIEW_POINT_TIERS: [
{ minChanges: 2000, points: 6 },
{ minChanges: 200, points: 4 },
{ minChanges: 0, points: 2 },
[
'docs',
{
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 2000, points: 6 },
{ minChanges: 200, points: 4 },
{ minChanges: 0, points: 2 },
],
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
},
],
EXCLUDED_FILES: [
'yarn.lock',
'.yarn/**/*',
'packages/component-library/src/icons/**/*',
'release-notes/**/*',
'upcoming-release-notes/**/*',
],
DOCS_FILES_PATTERN: 'packages/docs/**/*',
};
]);
/**
* Get the start and end dates for the last month.
@@ -65,14 +76,15 @@ function getLastMonthDates() {
/**
* Used for calculating the monthly points each core contributor has earned.
* These are used for payouts depending.
* @returns {Map} A map of contributor logins to their total points earned
* @param {string} repo - The repository to analyze ('actual' or 'docs')
* @returns {number} The total points earned for the repository
*/
async function countContributorPoints() {
async function countContributorPoints(repo) {
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const owner = 'actualbudget';
const repo = 'actual';
const config = REPOSITORY_CONFIG.get(repo);
const { since, until } = getLastMonthDates();
@@ -87,8 +99,7 @@ async function countContributorPoints() {
Array.from(orgMemberLogins).map(login => [
login,
{
codeReviews: [], // Will store objects with PR number and points for main repo changes
docsReviews: [], // Will store objects with PR number and points for docs changes
reviews: [], // Will store objects with PR number and points
labelRemovals: [],
issueClosings: [],
points: 0,
@@ -145,91 +156,48 @@ async function countContributorPoints() {
),
]);
const filteredFiles = modifiedFiles.filter(
file =>
!CONFIG.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern, { dot: true }),
),
);
const docsFiles = filteredFiles.filter(file =>
minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const codeFiles = filteredFiles.filter(
file =>
!minimatch(file.filename, CONFIG.DOCS_FILES_PATTERN, { dot: true }),
);
const docsChanges = docsFiles.reduce(
(sum, file) => sum + file.additions + file.deletions,
0,
);
const codeChanges = codeFiles.reduce(
(sum, file) => sum + file.additions + file.deletions,
0,
);
const docsPoints =
docsChanges > 0
? (CONFIG.DOCS_PR_REVIEW_POINT_TIERS.find(
t => docsChanges >= t.minChanges,
)?.points ?? 0)
: 0;
const codePoints =
codeChanges > 0 || docsChanges === 0
? (CONFIG.CODE_PR_REVIEW_POINT_TIERS.find(
t => codeChanges >= t.minChanges,
)?.points ?? 0)
: 0;
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
const prPoints =
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
?.points ?? 0;
if (isReleasePR) {
// release PRs are created by the github-actions bot so we attribute points to the merger
const { data: prDetails } = await octokit.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (prDetails.merged_by && stats.has(prDetails.merged_by.login)) {
const mergerStats = stats.get(prDetails.merged_by.login);
mergerStats.codeReviews.push({
if (stats.has(pr.user.login)) {
const creatorStats = stats.get(pr.user.login);
creatorStats.reviews.push({
pr: pr.number.toString(),
points: CONFIG.POINTS_PER_RELEASE_PR,
isReleaseMerger: true,
points: config.POINTS_PER_RELEASE_PR,
isReleaseCreator: true,
});
mergerStats.points += CONFIG.POINTS_PER_RELEASE_PR;
creatorStats.points += config.POINTS_PER_RELEASE_PR;
}
} else {
const uniqueReviewers = new Set();
reviews.data.forEach(review => {
if (
review.state === 'APPROVED' &&
stats.has(review.user?.login) &&
!uniqueReviewers.has(review.user?.login)
) {
const reviewer = review.user.login;
reviews.data
.filter(
review =>
stats.has(review.user?.login) &&
review.state === 'APPROVED' &&
!uniqueReviewers.has(review.user?.login),
)
.forEach(({ user: { login: reviewer } }) => {
uniqueReviewers.add(reviewer);
const userStats = stats.get(reviewer);
if (docsPoints > 0) {
userStats.docsReviews.push({
pr: pr.number.toString(),
points: docsPoints,
});
userStats.points += docsPoints;
}
if (codePoints > 0) {
userStats.codeReviews.push({
pr: pr.number.toString(),
points: codePoints,
});
userStats.points += codePoints;
}
}
});
userStats.reviews.push({
pr: pr.number.toString(),
points: prPoints,
});
userStats.points += prPoints;
});
}
}),
),
@@ -273,7 +241,7 @@ async function countContributorPoints() {
const remover = event.actor.login;
const userStats = stats.get(remover);
userStats.labelRemovals.push(issue.number.toString());
userStats.points += CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION;
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
}
if (
@@ -283,7 +251,7 @@ async function countContributorPoints() {
const closer = event.actor.login;
const userStats = stats.get(closer);
userStats.issueClosings.push(issue.number.toString());
userStats.points += CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION;
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
}
});
}),
@@ -292,39 +260,27 @@ async function countContributorPoints() {
// Print all statistics
printStats(
'Code Review Statistics',
stats => stats.codeReviews.length,
`PR Review Statistics (${repo})`,
stats => stats.reviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.codeReviews.map(r => {
if (r.isReleaseMerger) {
return `#${r.pr} (${r.points}pts - Release Merger)`;
.reviews.map(r => {
if (r.isReleaseCreator) {
return `#${r.pr} (${r.points}pts - Release Creator)`;
}
return `#${r.pr} (${r.points}pts)`;
})
.join(', ')})`,
);
printStats(
'Docs Review Statistics',
stats => stats.docsReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.docsReviews.map(r => `#${r.pr} (${r.points}pts)`)
.join(', ')})`,
);
printStats(
'"Needs Triage" Label Removal Statistics',
`"Needs Triage" Label Removal Statistics (${repo})`,
stats => stats.labelRemovals.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
);
printStats(
'Issue Closing Statistics',
`Issue Closing Statistics (${repo})`,
stats => stats.issueClosings.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
@@ -332,7 +288,7 @@ async function countContributorPoints() {
// Print points summary
printStats(
'Points Summary',
`Points Summary (${repo})`,
stats => stats.points,
(user, userPoints) => `${user}: ${userPoints}`,
);
@@ -342,7 +298,7 @@ async function countContributorPoints() {
(sum, userStats) => sum + userStats.points,
0,
);
console.log(`\nTotal points earned: ${totalPoints}`);
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
// Return the points
return new Map(
@@ -353,5 +309,55 @@ async function countContributorPoints() {
);
}
/**
* Calculate the points for both repositories and print cumulative results
*/
async function calculateCumulativePoints() {
// Get stats for each repository
const repoPointsResults = await Promise.all(
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
);
// Calculate cumulative stats
const cumulativeStats = new Map(repoPointsResults[0]);
// Combine stats from all repositories
for (let i = 1; i < repoPointsResults.length; i++) {
for (const [login, points] of repoPointsResults[i].entries()) {
if (!cumulativeStats.has(login)) {
cumulativeStats.set(login, 0);
}
cumulativeStats.set(login, cumulativeStats.get(login) + points);
}
}
// Print cumulative statistics
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
console.log('='.repeat(50));
console.log('\nCumulative Points Summary:');
console.log('='.repeat('Cumulative Points Summary'.length + 1));
const entries = Array.from(cumulativeStats.entries())
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
console.log('No cumulative points summary found.');
} else {
entries.forEach(([user, points]) => {
console.log(`${user}: ${points}`);
});
}
// Calculate and print total cumulative points
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
(sum, points) => sum + points,
0,
);
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
}
// Run the calculations
countContributorPoints().catch(console.error);
calculateCumulativePoints().catch(console.error);

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -15,7 +15,7 @@ jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Format code

View File

@@ -12,7 +12,6 @@ on:
branches:
- master
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -22,7 +21,7 @@ jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -31,23 +30,16 @@ jobs:
run: cd packages/api && yarn build
- name: Create package tgz
run: cd packages/api && yarn pack && mv package.tgz actual-api.tgz
- name: Prepare bundle stats artifact
run: cp packages/api/app/stats.json api-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-api
path: packages/api/actual-api.tgz
- name: Upload API bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: api-build-stats
path: api-stats.json
crdt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,7 +49,7 @@ jobs:
- name: Create package tgz
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
@@ -65,18 +57,18 @@ jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:browser
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: build-stats
path: packages/desktop-client/build-stats
@@ -84,7 +76,7 @@ jobs:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -92,7 +84,7 @@ jobs:
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: sync-server
path: packages/sync-server/build

View File

@@ -5,7 +5,6 @@ on:
branches:
- master
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -15,7 +14,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -25,7 +24,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -35,7 +34,7 @@ jobs:
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -47,7 +46,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -59,8 +58,8 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check migrations

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Count points

View File

@@ -36,17 +36,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -54,14 +54,14 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -76,7 +76,7 @@ jobs:
run: yarn build:server
- name: Build image for testing
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: false
@@ -93,7 +93,7 @@ jobs:
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -28,17 +28,17 @@ jobs:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
@@ -48,7 +48,7 @@ jobs:
- name: Docker meta for Alpine image
id: alpine-meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGES }}
# Automatically update :latest
@@ -58,13 +58,13 @@ jobs:
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -78,7 +78,7 @@ jobs:
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: true
@@ -87,7 +87,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v5
with:
context: .
push: true

View File

@@ -1,164 +0,0 @@
name: Check Spelling (Docs)
# Comment management is handled through a secondary job, for details see:
# https://github.com/check-spelling/check-spelling/wiki/Feature%3A-Restricted-Permissions
#
# `jobs.comment-push` runs when a push is made to a repository and the `jobs.spelling` job needs to make a comment
# (in odd cases, it might actually run just to collapse a comment, but that's fairly rare)
# it needs `contents: write` in order to add a comment.
#
# `jobs.comment-pr` runs when a pull_request is made to a repository and the `jobs.spelling` job needs to make a comment
# or collapse a comment (in the case where it had previously made a comment and now no longer needs to show a comment)
# it needs `pull-requests: write` in order to manipulate those comments.
# Updating pull request branches is managed via comment handling.
# For details, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-expect-list
#
# These elements work together to make it happen:
#
# `on.issue_comment`
# This event listens to comments by users asking to update the metadata.
#
# `jobs.update`
# This job runs in response to an issue_comment and will push a new commit
# to update the spelling metadata.
#
# `with.experimental_apply_changes_via_bot`
# Tells the action to support and generate messages that enable it
# to make a commit to update the spelling metadata.
#
# `with.ssh_key`
# In order to trigger workflows when the commit is made, you can provide a
# secret (typically, a write-enabled github deploy key).
#
# For background, see: https://github.com/check-spelling/check-spelling/wiki/Feature:-Update-with-deploy-key
on:
push:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'packages/docs/**'
- '.github/workflows/docs-spelling.yml'
- '.github/actions/docs-spelling/**'
pull_request_target:
branches:
- '**'
tags-ignore:
- '**'
paths:
- 'packages/docs/**'
- '.github/workflows/docs-spelling.yml'
- '.github/actions/docs-spelling/**'
types:
- 'opened'
- 'reopened'
- 'synchronize'
issue_comment:
types:
- 'created'
jobs:
spelling:
name: Check Spelling
permissions:
contents: read
pull-requests: read
actions: read
security-events: write
outputs:
followup: ${{ steps.spelling.outputs.followup }}
runs-on: ubuntu-latest
if: "contains(github.event_name, 'pull_request') || github.event_name == 'push'"
concurrency:
group: spelling-${{ github.event.pull_request.number || github.ref }}
# note: If you use only_check_changed_files, you do not want cancel-in-progress
cancel-in-progress: true
steps:
- name: check-spelling
id: spelling
uses: check-spelling/check-spelling@main
with:
suppress_push_for_open_pull_request: 1
checkout: true
check_file_names: 1
spell_check_this: check-spelling/spell-check-this@prerelease
post_comment: 0
use_magic_file: 1
experimental_apply_changes_via_bot: 1
use_sarif: 1
extra_dictionary_limit: 12
check_extra_dictionaries: ''
extra_dictionaries: cspell:cpp/src/cpp.txt
cspell:software-terms/src/software-terms.txt
cspell:python/src/python/python-lib.txt
cspell:node/node.txt
cspell:filetypes/filetypes.txt
cspell:aws/aws.txt
cspell:typescript/dict/typescript.txt
cspell:npm/dict/npm.txt
cspell:fullstack/dict/fullstack.txt
cspell:html/dict/html.txt
cspell:css/dict/css.txt
config: .github/actions/docs-spelling
comment-push:
name: Report (Push)
# If your workflow isn't running on push, you can remove this job
runs-on: ubuntu-latest
needs: spelling
permissions:
contents: write
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
steps:
- name: comment
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
config: .github/actions/docs-spelling
comment-pr:
name: Report (PR)
# If you workflow isn't running on pull_request*, you can remove this job
runs-on: ubuntu-latest
needs: spelling
permissions:
pull-requests: write
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
steps:
- name: comment
uses: check-spelling/check-spelling@main
with:
checkout: true
spell_check_this: check-spelling/spell-check-this@prerelease
task: ${{ needs.spelling.outputs.followup }}
experimental_apply_changes_via_bot: 1
config: .github/actions/docs-spelling
update:
name: Update PR
permissions:
contents: write
pull-requests: write
actions: read
runs-on: ubuntu-latest
if: ${{
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@check-spelling-bot apply')
}}
concurrency:
group: spelling-update-${{ github.event.issue.number }}
cancel-in-progress: false
steps:
- name: apply spelling updates
uses: check-spelling/check-spelling@main
with:
experimental_apply_changes_via_bot: 1
checkout: true
ssh_key: '${{ secrets.CHECK_SPELLING }}'
config: .github/actions/docs-spelling

View File

@@ -17,7 +17,7 @@ jobs:
outputs:
netlify_url: ${{ steps.netlify.outputs.url }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Wait for Netlify build to finish
@@ -34,7 +34,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
@@ -43,7 +43,7 @@ jobs:
run: yarn e2e
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results
@@ -57,7 +57,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Trust the repository directory
@@ -65,7 +65,7 @@ jobs:
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-app-test-results
@@ -80,14 +80,14 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Netlify URL
run: yarn vrt
env:
E2E_START_URL: ${{ needs.netlify.outputs.netlify_url }}
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-client-test-results

View File

@@ -29,7 +29,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -62,7 +62,7 @@ jobs:
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}
path: |
@@ -73,7 +73,7 @@ jobs:
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
@@ -83,7 +83,7 @@ jobs:
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Add to new release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@v2
with:
draft: true
body: |
@@ -113,7 +113,7 @@ jobs:
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
- name: Download Microsoft Store artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: actual-electron-windows-latest-appx

View File

@@ -24,7 +24,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -46,66 +46,19 @@ jobs:
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
name: actual-electron-${{ matrix.os }}
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
- name: Bump package versions
@@ -48,7 +48,7 @@ jobs:
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@v7
with:
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'

View File

@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
path: actual
- name: Set up environment
@@ -44,7 +44,7 @@ jobs:
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations

View File

@@ -9,17 +9,16 @@ jobs:
if: ${{ github.event.label.name == 'feature' }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
- uses: actions-ecosystem/action-add-labels@v1
with:
labels: needs votes
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Add reactions
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
uses: aidan-mundy/react-to-issue@v1.1.1
with:
issue-number: ${{ github.event.issue.number }}
reactions: '+1'
- name: Create comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
uses: peter-evans/create-or-update-comment@v3
with:
issue-number: ${{ github.event.issue.number }}
body: |

View File

@@ -24,8 +24,8 @@ jobs:
runs-on: ubuntu-latest
steps:
# This is not a security concern because we have approved & merged the PR
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Handle feature requests

View File

@@ -9,6 +9,6 @@ jobs:
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
runs-on: ubuntu-latest
steps:
- uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: help wanted

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -1,131 +0,0 @@
name: Publish nightly desktop app
# Publish nightly version of desktop app - Runs every day at midnight
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
defaults:
run:
shell: bash
env:
CI: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
strategy:
matrix:
os:
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
run: |
mkdir .venv
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
sudo apt-get update
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
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 }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx

View File

@@ -12,7 +12,7 @@ jobs:
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
@@ -49,7 +49,7 @@ jobs:
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: npm-packages
path: |
@@ -66,12 +66,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
@@ -32,7 +32,7 @@ jobs:
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: npm-packages
path: |
@@ -49,12 +49,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'

View File

@@ -12,21 +12,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
run: |
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
echo "only_docs=true" >> $GITHUB_OUTPUT
echo "only documentation files changed, skipping release notes check"
else
echo "only_docs=false" >> $GITHUB_OUTPUT
fi
uses: actions/checkout@v4
- name: Check release notes
if: startsWith(github.head_ref, 'release/') == false
uses: actualbudget/actions/release-notes/check@main
- name: Generate release notes
if: startsWith(github.head_ref, 'release/') == true
uses: actualbudget/actions/release-notes/generate@main

View File

@@ -26,73 +26,40 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Wait for ${{github.base_ref}} web build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-web-build
- name: Wait for ${{github.base_ref}} build to succeed
uses: fountainhead/action-wait-for-check@v1.2.0
id: master-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.base_ref}}
- name: Wait for ${{github.base_ref}} API build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: master-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-web-build
uses: fountainhead/action-wait-for-check@v1.2.0
id: wait-for-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: web
ref: ${{github.event.pull_request.head.sha}}
- name: Wait for API PR build to succeed
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
id: wait-for-api-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
checkName: api
ref: ${{github.event.pull_request.head.sha}}
- name: Report build failure
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
if: steps.wait-for-build.outputs.conclusion == 'failure'
run: |
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@v6
id: pr-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
- name: Download web build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-web-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-api-build
with:
branch: ${{github.base_ref}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
- name: Download build artifact from PR
uses: dawidd6/action-download-artifact@v6
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -100,46 +67,25 @@ jobs:
name: build-stats
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
name: api-build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
if [ -f ./head/web-stats.json ]; then
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
fi
if [ -f ./base/web-stats.json ]; then
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
fi
for file in ./head/*.json ./base/*.json; do
if [ -f "$file" ]; then
sed -i -E 's/\.[0-9a-f]{8,}\././g' "$file"
fi
done
- name: Generate combined bundle stats comment
run: |
node packages/ci-actions/bin/bundle-stats-comment.mjs \
--base desktop-client=./base/web-stats.json \
--base loot-core=./base/loot-core-stats.json \
--base api=./base/api-stats.json \
--head desktop-client=./head/web-stats.json \
--head loot-core=./head/loot-core-stats.json \
--head api=./head/api-stats.json \
--identifier combined > bundle-stats-comment.md
- name: Post combined bundle stats comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
--comment-file bundle-stats-comment.md \
--identifier '<!--- bundlestats-action-comment key:combined --->'
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: twk3/rollup-size-compare-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/web-stats.json
base-stats-json-path: ./base/web-stats.json
title: desktop-client
- uses: twk3/rollup-size-compare-action@v1.1.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/loot-core-stats.json
base-stats-json-path: ./base/loot-core-stats.json
title: loot-core

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
@@ -18,7 +18,7 @@ jobs:
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
@@ -29,7 +29,7 @@ jobs:
stale-needs-info:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@v9
with:
stale-issue-label: 'needs info'
days-before-stale: -1

View File

@@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -27,7 +27,7 @@ jobs:
path: /tmp/artifacts
- name: Download metadata artifact
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -37,14 +37,17 @@ jobs:
- name: Extract metadata
id: metadata
run: |
if [ ! -f "/tmp/metadata/pr-number.txt" ]; then
# Find the metadata directory (will be vrt-metadata-{PR_NUMBER})
METADATA_DIR=$(find /tmp/metadata -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [ -z "$METADATA_DIR" ]; then
echo "No metadata found, skipping..."
exit 0
fi
PR_NUMBER=$(cat "/tmp/metadata/pr-number.txt")
HEAD_REF=$(cat "/tmp/metadata/head-ref.txt")
HEAD_REPO=$(cat "/tmp/metadata/head-repo.txt")
PR_NUMBER=$(cat "$METADATA_DIR/pr-number.txt")
HEAD_REF=$(cat "$METADATA_DIR/head-ref.txt")
HEAD_REPO=$(cat "$METADATA_DIR/head-repo.txt")
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
@@ -54,7 +57,7 @@ jobs:
- name: Checkout fork branch
if: steps.metadata.outputs.pr_number != ''
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}
@@ -65,7 +68,9 @@ jobs:
if: steps.metadata.outputs.pr_number != ''
id: apply
run: |
PATCH_FILE="/tmp/artifacts/vrt-update.patch"
# Find the patch file
PATCH_DIR=$(find /tmp/artifacts -mindepth 1 -maxdepth 1 -type d | head -n 1)
PATCH_FILE="$PATCH_DIR/vrt-update.patch"
if [ ! -f "$PATCH_FILE" ]; then
echo "No patch file found"
@@ -127,7 +132,7 @@ jobs:
- name: Comment on PR - Success
if: steps.apply.outputs.applied == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
@@ -139,7 +144,7 @@ jobs:
- name: Comment on PR - Failure
if: failure() && steps.metadata.outputs.pr_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@v7
with:
script: |
const error = `${{ steps.apply.outputs.error }}` || 'Unknown error occurred';

View File

@@ -22,7 +22,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -82,7 +82,7 @@ jobs:
- name: Upload patch artifact
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: vrt-patch-${{ github.event.pull_request.number }}
path: vrt-update.patch
@@ -98,7 +98,7 @@ jobs:
- name: Upload PR metadata
if: steps.create-patch.outputs.has_changes == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@v4
with:
name: vrt-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/

View File

@@ -10,7 +10,6 @@ packages/desktop-client/bundle.browser.js
packages/desktop-client/stats.json
packages/desktop-client/.swc/
packages/desktop-client/build/
packages/desktop-client/dev-dist/
packages/desktop-client/locale/
packages/desktop-client/build-electron/
packages/desktop-client/build-stats/
@@ -27,10 +26,5 @@ packages/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
packages/sync-server/coverage/
packages/sync-server/user-files/
packages/sync-server/server-files/
.yarn/*
upcoming-release-notes/*
# temporary
packages/docs/*

View File

@@ -7,7 +7,7 @@ This guide provides comprehensive information for AI agents (like Cursor) workin
**Actual Budget** is a local-first personal finance tool written in TypeScript/JavaScript. It's 100% free and open-source with synchronization capabilities across devices.
- **Repository**: https://github.com/actualbudget/actual
- **Community Docs**: https://github.com/actualbudget/actual/tree/master/packages/docs or https://actualbudget.org/docs
- **Community Docs**: https://github.com/actualbudget/docs or https://actualbudget.org/docs
- **License**: MIT
- **Primary Language**: TypeScript (with React)
- **Build System**: Yarn 4 workspaces (monorepo)

View File

@@ -1,16 +1,16 @@
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import globals from 'globals';
import pluginTypescript from 'typescript-eslint';
// eslint-disable-next-line import/extensions
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
import tsParser from '@typescript-eslint/parser';
const confusingBrowserGlobals = [
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
'addEventListener',
@@ -75,33 +75,34 @@ const confusingBrowserGlobals = [
export default defineConfig(
{
ignores: [
//temporary
'packages/docs',
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/dev-dist/',
'packages/desktop-client/build/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
'packages/desktop-electron/build/',
'packages/desktop-electron/dist/',
'packages/loot-core/**/node_modules/*',
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/user-files/',
'packages/sync-server/server-files/',
'packages/sync-server/build/',
'packages/plugins-service/dist/',
'.yarn/*',
'.github/*',
'**/build/',
'**/dist/',
'**/node_modules/',
],
},
{
@@ -163,7 +164,7 @@ export default defineConfig(
},
},
{
files: ['**/*.{js,ts,jsx,tsx,mjs,mts}'],
files: ['**/*.{js,ts,jsx,tsx}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
@@ -447,7 +448,7 @@ export default defineConfig(
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useQuery|useEffectAfterMount)',
additionalHooks: '(useQuery)',
},
],
@@ -664,7 +665,7 @@ export default defineConfig(
'warn',
{
types: {
// forbid FC as superfluous
// forbid FC as superflous
FunctionComponent: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
@@ -709,18 +710,14 @@ export default defineConfig(
// Allow configuring vitest with default exports (recommended as per vitest docs)
{
files: [
'**/vitest.config.{ts,mts}',
'**/vitest.web.config.ts',
'**/vite.config.{ts,mts}',
'eslint.config.mjs',
],
files: ['**/vitest.config.ts', '**/vitest.web.config.ts'],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
{},
{
// TODO: fix the issues in these files
files: [

View File

@@ -23,7 +23,6 @@
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"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",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
@@ -39,7 +38,6 @@
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"build:docs": "yarn workspace docs build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",
@@ -59,34 +57,34 @@
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.1",
"@octokit/rest": "^22.0.0",
"@types/node": "^22.18.11",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.46.4",
"@typescript-eslint/parser": "^8.46.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^16.5.0",
"globals": "^16.4.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.15",
"lint-staged": "^16.2.6",
"minimatch": "^10.1.1",
"lage": "^2.14.14",
"lint-staged": "^16.2.3",
"minimatch": "^10.0.3",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"p-limit": "^7.2.0",
"p-limit": "^7.1.1",
"prettier": "^3.6.2",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"typescript-eslint": "^8.46.0",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {

View File

@@ -1,8 +1,7 @@
// @ts-strict-ignore
import * as fs from 'fs/promises';
import * as path from 'path';
import { type RuleEntity } from 'loot-core/types/models';
import * as api from './index';
const budgetName = 'test-budget';
@@ -283,7 +282,7 @@ describe('API CRUD operations', () => {
expect(await api.getAccountBalance(accountId2)).toEqual(0);
await api.updateAccount(accountId1, { offbudget: false });
await api.closeAccount(accountId1, accountId2);
await api.closeAccount(accountId1, accountId2, null);
await api.deleteAccount(accountId2);
// accounts successfully updated, and one of them deleted
@@ -506,7 +505,7 @@ describe('API CRUD operations', () => {
...rule,
stage: 'post',
conditionsOp: 'or',
} satisfies RuleEntity;
};
expect(await api.updateRule(updatedRule)).toEqual(updatedRule);
expect(await api.getRules()).toEqual(
@@ -720,7 +719,7 @@ describe('API CRUD operations', () => {
// Test without notes
const transactionsWithoutNotes = [
{ date: '2023-11-03', imported_id: '11', amount: 100 },
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: null },
];
const addResultWithoutNotes = await api.addTransactions(

View File

@@ -1,18 +1,6 @@
import type {
APIAccountEntity,
APICategoryEntity,
APICategoryGroupEntity,
APIFileEntity,
APIPayeeEntity,
APIScheduleEntity,
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
// @ts-strict-ignore
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
import * as injected from './injected';
@@ -25,11 +13,8 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
return injected.send(name, args);
}
export async function runImport(
budgetName: APIFileEntity['name'],
func: () => Promise<void>,
) {
await send('api/start-import', { budgetName });
export async function runImport(name, func) {
await send('api/start-import', { budgetName: name });
try {
await func();
} catch (e) {
@@ -39,14 +24,11 @@ export async function runImport(
await send('api/finish-import');
}
export async function loadBudget(budgetId: string) {
export async function loadBudget(budgetId) {
return send('api/load-budget', { id: budgetId });
}
export async function downloadBudget(
syncId: string,
{ password }: { password?: string } = {},
) {
export async function downloadBudget(syncId, { password }: { password? } = {}) {
return send('api/download-budget', { syncId, password });
}
@@ -58,13 +40,11 @@ export async function sync() {
return send('api/sync');
}
export async function runBankSync(args?: {
accountId: APIAccountEntity['id'];
}) {
export async function runBankSync(args?: { accountId: string }) {
return send('api/bank-sync', args);
}
export async function batchBudgetUpdates(func: () => Promise<void>) {
export async function batchBudgetUpdates(func) {
await send('api/batch-budget-start');
try {
await func();
@@ -77,11 +57,11 @@ export async function batchBudgetUpdates(func: () => Promise<void>) {
* @deprecated Please use `aqlQuery` instead.
* This function will be removed in a future release.
*/
export function runQuery(query: Query) {
export function runQuery(query) {
return send('api/query', { query: query.serialize() });
}
export function aqlQuery(query: Query) {
export function aqlQuery(query) {
return send('api/query', { query: query.serialize() });
}
@@ -89,33 +69,22 @@ export function getBudgetMonths() {
return send('api/budget-months');
}
export function getBudgetMonth(month: string) {
export function getBudgetMonth(month) {
return send('api/budget-month', { month });
}
export function setBudgetAmount(
month: string,
categoryId: APICategoryEntity['id'],
value: number,
) {
export function setBudgetAmount(month, categoryId, value) {
return send('api/budget-set-amount', { month, categoryId, amount: value });
}
export function setBudgetCarryover(
month: string,
categoryId: APICategoryEntity['id'],
flag: boolean,
) {
export function setBudgetCarryover(month, categoryId, flag) {
return send('api/budget-set-carryover', { month, categoryId, flag });
}
export function addTransactions(
accountId: APIAccountEntity['id'],
transactions: Omit<ImportTransactionEntity, 'account'>[],
{
learnCategories = false,
runTransfers = false,
}: { learnCategories?: boolean; runTransfers?: boolean } = {},
accountId,
transactions,
{ learnCategories = false, runTransfers = false } = {},
) {
return send('api/transactions-add', {
accountId,
@@ -131,7 +100,7 @@ export interface ImportTransactionsOpts {
}
export function importTransactions(
accountId: APIAccountEntity['id'],
accountId: string,
transactions: ImportTransactionEntity[],
opts: ImportTransactionsOpts = {
defaultCleared: true,
@@ -146,22 +115,15 @@ export function importTransactions(
});
}
export function getTransactions(
accountId: APIAccountEntity['id'],
startDate: string,
endDate: string,
) {
export function getTransactions(accountId, startDate, endDate) {
return send('api/transactions-get', { accountId, startDate, endDate });
}
export function updateTransaction(
id: TransactionEntity['id'],
fields: Partial<TransactionEntity>,
) {
export function updateTransaction(id, fields) {
return send('api/transaction-update', { id, fields });
}
export function deleteTransaction(id: TransactionEntity['id']) {
export function deleteTransaction(id) {
return send('api/transaction-delete', { id });
}
@@ -169,25 +131,15 @@ export function getAccounts() {
return send('api/accounts-get');
}
export function createAccount(
account: Omit<APIAccountEntity, 'id'>,
initialBalance?: number,
) {
export function createAccount(account, initialBalance?) {
return send('api/account-create', { account, initialBalance });
}
export function updateAccount(
id: APIAccountEntity['id'],
fields: Partial<APIAccountEntity>,
) {
export function updateAccount(id, fields) {
return send('api/account-update', { id, fields });
}
export function closeAccount(
id: APIAccountEntity['id'],
transferAccountId?: APIAccountEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function closeAccount(id, transferAccountId?, transferCategoryId?) {
return send('api/account-close', {
id,
transferAccountId,
@@ -195,15 +147,15 @@ export function closeAccount(
});
}
export function reopenAccount(id: APIAccountEntity['id']) {
export function reopenAccount(id) {
return send('api/account-reopen', { id });
}
export function deleteAccount(id: APIAccountEntity['id']) {
export function deleteAccount(id) {
return send('api/account-delete', { id });
}
export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
export function getAccountBalance(id, cutoff?) {
return send('api/account-balance', { id, cutoff });
}
@@ -211,21 +163,15 @@ export function getCategoryGroups() {
return send('api/category-groups-get');
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
export function createCategoryGroup(group) {
return send('api/category-group-create', { group });
}
export function updateCategoryGroup(
id: APICategoryGroupEntity['id'],
fields: Partial<APICategoryGroupEntity>,
) {
export function updateCategoryGroup(id, fields) {
return send('api/category-group-update', { id, fields });
}
export function deleteCategoryGroup(
id: APICategoryGroupEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function deleteCategoryGroup(id, transferCategoryId?) {
return send('api/category-group-delete', { id, transferCategoryId });
}
@@ -233,21 +179,15 @@ export function getCategories() {
return send('api/categories-get', { grouped: false });
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
export function createCategory(category) {
return send('api/category-create', { category });
}
export function updateCategory(
id: APICategoryEntity['id'],
fields: Partial<APICategoryEntity>,
) {
export function updateCategory(id, fields) {
return send('api/category-update', { id, fields });
}
export function deleteCategory(
id: APICategoryEntity['id'],
transferCategoryId?: APICategoryEntity['id'],
) {
export function deleteCategory(id, transferCategoryId?) {
return send('api/category-delete', { id, transferCategoryId });
}
@@ -259,25 +199,19 @@ export function getPayees() {
return send('api/payees-get');
}
export function createPayee(payee: Omit<APIPayeeEntity, 'id'>) {
export function createPayee(payee) {
return send('api/payee-create', { payee });
}
export function updatePayee(
id: APIPayeeEntity['id'],
fields: Partial<APIPayeeEntity>,
) {
export function updatePayee(id, fields) {
return send('api/payee-update', { id, fields });
}
export function deletePayee(id: APIPayeeEntity['id']) {
export function deletePayee(id) {
return send('api/payee-delete', { id });
}
export function mergePayees(
targetId: APIPayeeEntity['id'],
mergeIds: APIPayeeEntity['id'][],
) {
export function mergePayees(targetId, mergeIds) {
return send('api/payees-merge', { targetId, mergeIds });
}
@@ -285,39 +219,35 @@ export function getRules() {
return send('api/rules-get');
}
export function getPayeeRules(id: RuleEntity['id']) {
export function getPayeeRules(id) {
return send('api/payee-rules-get', { id });
}
export function createRule(rule: Omit<RuleEntity, 'id'>) {
export function createRule(rule) {
return send('api/rule-create', { rule });
}
export function updateRule(rule: RuleEntity) {
export function updateRule(rule) {
return send('api/rule-update', { rule });
}
export function deleteRule(id: RuleEntity['id']) {
export function deleteRule(id: string) {
return send('api/rule-delete', id);
}
export function holdBudgetForNextMonth(month: string, amount: number) {
export function holdBudgetForNextMonth(month, amount) {
return send('api/budget-hold-for-next-month', { month, amount });
}
export function resetBudgetHold(month: string) {
export function resetBudgetHold(month) {
return send('api/budget-reset-hold', { month });
}
export function createSchedule(schedule: Omit<APIScheduleEntity, 'id'>) {
export function createSchedule(schedule) {
return send('api/schedule-create', schedule);
}
export function updateSchedule(
id: APIScheduleEntity['id'],
fields: Partial<APIScheduleEntity>,
resetNextDate?: boolean,
) {
export function updateSchedule(id, fields, resetNextDate?: boolean) {
return send('api/schedule-update', {
id,
fields,
@@ -325,7 +255,7 @@ export function updateSchedule(
});
}
export function deleteSchedule(scheduleId: APIScheduleEntity['id']) {
export function deleteSchedule(scheduleId) {
return send('api/schedule-delete', scheduleId);
}
@@ -333,10 +263,7 @@ export function getSchedules() {
return send('api/schedules-get');
}
export function getIDByName(
type: 'accounts' | 'schedules' | 'categories' | 'payees',
name: string,
) {
export function getIDByName(type, name) {
return send('api/get-id-by-name', { type, name });
}

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "25.11.0",
"version": "25.10.0",
"license": "MIT",
"description": "An API for Actual",
"engines": {
@@ -32,6 +32,6 @@
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"vitest": "^4.0.9"
"vitest": "^3.2.4"
}
}

View File

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

View File

@@ -1,678 +0,0 @@
#!/usr/bin/env node
/**
* Generates a combined bundle stats comment for GitHub Actions.
* Heavily inspired by https://github.com/twk3/rollup-size-compare-action (MIT).
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const REQUIRED_ARGS = new Map([
['base', 'Mapping of bundle names to base stats JSON'],
['head', 'Mapping of bundle names to head stats JSON'],
]);
function parseRawArgs(argv) {
const args = new Map();
for (let index = 2; index < argv.length; index += 1) {
const key = argv[index];
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument “${key ?? ''}”. Use --key value pairs.`,
);
}
const values = [];
while (index + 1 < argv.length && !argv[index + 1].startsWith('--')) {
values.push(argv[index + 1]);
index += 1;
}
if (values.length === 0) {
throw new Error(`Missing value for argument “${key}”.`);
}
const keyName = key.slice(2);
// Accumulate values if the key already exists
if (args.has(keyName)) {
args.set(keyName, [...args.get(keyName), ...values]);
} else {
args.set(keyName, values);
}
}
return args;
}
function getSingleValue(args, key) {
const values = args.get(key);
if (!values) {
return undefined;
}
if (values.length !== 1) {
throw new Error(`Argument “--${key}” must have exactly one value.`);
}
return values[0];
}
function parseMapping(values, key, description) {
if (!values || values.length === 0) {
throw new Error(`Missing required argument “--${key}” (${description}).`);
}
if (values.length === 1) {
const [rawValue] = values;
const trimmed = rawValue.trim();
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Value must be a JSON object.');
}
return new Map(
Object.entries(parsed).map(([name, pathValue]) => {
if (typeof pathValue !== 'string') {
throw new Error(
`Value for “${name}” in “--${key}” must be a string path.`,
);
}
return [name, pathValue];
}),
);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown parsing error';
throw new Error(
`Failed to parse “--${key}” value as JSON object: ${message}`,
);
}
}
}
const entries = new Map();
for (const value of values) {
const [rawName, ...rawPathParts] = value.split('=');
if (!rawName || rawPathParts.length === 0) {
throw new Error(
`Argument “--${key}” must be provided as name=path pairs or a JSON object.`,
);
}
const name = rawName.trim();
const pathValue = rawPathParts.join('=').trim();
if (!name) {
throw new Error(`Argument “--${key}” contains an empty bundle name.`);
}
if (!pathValue) {
throw new Error(
`Argument “--${key}” for bundle “${name}” must include a non-empty path.`,
);
}
entries.set(name, pathValue);
}
if (entries.size === 0) {
throw new Error(`Argument “--${key}” must define at least one bundle.`);
}
return entries;
}
function parseArgs(argv) {
const args = parseRawArgs(argv);
const baseMap = parseMapping(
args.get('base'),
'base',
REQUIRED_ARGS.get('base'),
);
const headMap = parseMapping(
args.get('head'),
'head',
REQUIRED_ARGS.get('head'),
);
const sections = [];
for (const [name, basePath] of baseMap.entries()) {
const headPath = headMap.get(name);
if (!headPath) {
throw new Error(
`Bundle “${name}” is missing a corresponding “--head” entry.`,
);
}
sections.push({
name,
basePath,
headPath,
});
}
for (const name of headMap.keys()) {
if (!baseMap.has(name)) {
throw new Error(
`Bundle “${name}” is missing a corresponding “--base” entry.`,
);
}
}
return {
sections,
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
};
}
async function loadStats(filePath) {
try {
const absolutePath = path.resolve(process.cwd(), filePath);
const fileContents = await readFile(absolutePath, 'utf8');
const parsed = JSON.parse(fileContents);
// Validate that we got a meaningful stats object
if (!parsed || typeof parsed !== 'object') {
throw new Error('Stats file does not contain a valid JSON object');
}
return parsed;
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Unknown error while parsing stats file';
console.error(`[bundle-stats] Failed to parse “${filePath}”: ${message}`);
throw new Error(`Failed to load stats file “${filePath}”: ${message}`);
}
}
function findAllChildren(node = {}) {
if (Array.isArray(node.children)) {
return node.children.flatMap(findAllChildren);
}
return [node];
}
function trimPath(input) {
if (!input) {
return '';
}
return input.replace(/.*node_modules/, '/node_modules');
}
function assetNameToSizeMap(statAssets = {}) {
const children = statAssets?.tree?.children;
if (!Array.isArray(children) || children.length === 0) {
return new Map();
}
return new Map(
children.map(asset => {
const descendants = findAllChildren(asset);
let size = 0;
let gzipSize = statAssets?.options?.gzip ? 0 : null;
for (const mod of descendants) {
const nodePart = statAssets?.nodeParts?.[mod.uid];
if (!nodePart) {
continue;
}
size += nodePart.renderedLength ?? 0;
if (gzipSize !== null) {
gzipSize += nodePart.gzipLength ?? 0;
}
}
return [trimPath(asset.name), { size, gzipSize }];
}),
);
}
function chunkModuleNameToSizeMap(statChunks = {}) {
if (!statChunks?.tree) {
return new Map();
}
return new Map(
findAllChildren(statChunks.tree).map(mod => {
const modInfo = statChunks?.nodeParts?.[mod.uid] ?? {};
const meta = statChunks?.nodeMetas?.[modInfo.metaUid] ?? {};
const id = trimPath(meta.id ?? '');
return [
id,
{
size: modInfo.renderedLength ?? 0,
gzipSize: statChunks?.options?.gzip
? (modInfo.gzipLength ?? 0)
: null,
},
];
}),
);
}
function sortDiffDescending(items) {
return items.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff));
}
function normaliseGzip(value) {
if (value == null || Number.isNaN(value)) {
return NaN;
}
return value;
}
function getAssetDiff(name, oldSize, newSize) {
const diff = newSize.size - oldSize.size;
const percent =
oldSize.size === 0
? newSize.size === 0
? 0
: Infinity
: +((1 - newSize.size / oldSize.size) * -100).toFixed(5) || 0;
return {
name,
new: {
size: newSize.size,
gzipSize: normaliseGzip(newSize.gzipSize),
},
old: {
size: oldSize.size,
gzipSize: normaliseGzip(oldSize.gzipSize),
},
diff,
diffPercentage: percent,
};
}
function webpackStatsDiff(oldAssets, newAssets) {
const added = [];
const removed = [];
const bigger = [];
const smaller = [];
const unchanged = [];
let newSizeTotal = 0;
let oldSizeTotal = 0;
let newGzipSizeTotal = 0;
let oldGzipSizeTotal = 0;
for (const [name, oldAssetSizes] of oldAssets) {
oldSizeTotal += oldAssetSizes.size;
oldGzipSizeTotal += oldAssetSizes.gzipSize ?? NaN;
const newAsset = newAssets.get(name);
if (!newAsset) {
removed.push(getAssetDiff(name, oldAssetSizes, { size: 0, gzipSize: 0 }));
continue;
}
const diff = getAssetDiff(name, oldAssetSizes, newAsset);
if (diff.diffPercentage > 0) {
bigger.push(diff);
} else if (diff.diffPercentage < 0) {
smaller.push(diff);
} else {
unchanged.push(diff);
}
}
for (const [name, newAssetSizes] of newAssets) {
newSizeTotal += newAssetSizes.size;
newGzipSizeTotal += newAssetSizes.gzipSize ?? NaN;
if (!oldAssets.has(name)) {
added.push(getAssetDiff(name, { size: 0, gzipSize: 0 }, newAssetSizes));
}
}
const oldFilesCount = oldAssets.size;
const newFilesCount = newAssets.size;
return {
added: sortDiffDescending(added),
removed: sortDiffDescending(removed),
bigger: sortDiffDescending(bigger),
smaller: sortDiffDescending(smaller),
unchanged,
total: getAssetDiff(
oldFilesCount === newFilesCount
? `${newFilesCount}`
: `${oldFilesCount}${newFilesCount}`,
{ size: oldSizeTotal, gzipSize: oldGzipSizeTotal },
{ size: newSizeTotal, gzipSize: newGzipSizeTotal },
),
};
}
function getStatsDiff(oldStats, newStats) {
return webpackStatsDiff(
assetNameToSizeMap(oldStats),
assetNameToSizeMap(newStats),
);
}
function getChunkModuleDiff(oldStats, newStats) {
const diff = webpackStatsDiff(
chunkModuleNameToSizeMap(oldStats),
chunkModuleNameToSizeMap(newStats),
);
if (
diff.added.length === 0 &&
diff.removed.length === 0 &&
diff.bigger.length === 0 &&
diff.smaller.length === 0
) {
return null;
}
return diff;
}
const BYTES_PER_KILOBYTE = 1024;
const FILE_SIZE_DENOMINATIONS = [
'B',
'kB',
'MB',
'GB',
'TB',
'PB',
'EB',
'ZB',
'YB',
'BB',
];
function formatFileSizeIEC(bytes, precision = 2) {
if (bytes == null || Number.isNaN(bytes)) {
return 'N/A';
}
if (bytes === 0) {
return `0 ${FILE_SIZE_DENOMINATIONS[0]}`;
}
const absBytes = Math.abs(bytes);
const denominationIndex = Math.floor(
Math.log(absBytes) / Math.log(BYTES_PER_KILOBYTE),
);
const value = absBytes / Math.pow(BYTES_PER_KILOBYTE, denominationIndex);
const stripped = parseFloat(value.toFixed(precision));
return `${stripped} ${FILE_SIZE_DENOMINATIONS[denominationIndex]}`;
}
function conditionalPercentage(number) {
if (number === Infinity || number === -Infinity) {
return '-';
}
const absValue = Math.abs(number);
if (absValue === 0 || absValue === 100) {
return `${number}%`;
}
const value = Number.isFinite(absValue) ? absValue.toFixed(2) : absValue;
return `${signFor(number)}${value}%`;
}
function capitalize(text) {
if (!text) return '';
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
function makeHeader(columns) {
const header = columns.join(' | ');
const separator = columns
.map(column =>
Array.from({ length: column.length })
.map(() => '-')
.join(''),
)
.join(' | ');
return `${header}\n${separator}`;
}
const TOTAL_HEADERS = makeHeader([
'Files count',
'Total bundle size',
'% Changed',
]);
const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']);
const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']);
function signFor(num) {
if (num === 0) return '';
return num > 0 ? '+' : '-';
}
function toFileSizeDiff(oldSize, newSize, diff) {
const diffLine = [
`${formatFileSizeIEC(oldSize)}${formatFileSizeIEC(newSize)}`,
];
if (typeof diff !== 'undefined') {
diffLine.push(`(${signFor(diff)}${formatFileSizeIEC(diff)})`);
}
return diffLine.join(' ');
}
function toFileSizeDiffCell(asset) {
const lines = [];
if (asset.diff === 0) {
lines.push(formatFileSizeIEC(asset.new.size));
if (asset.new.gzipSize) {
lines.push(formatFileSizeIEC(asset.new.gzipSize));
}
} else {
lines.push(toFileSizeDiff(asset.old.size, asset.new.size, asset.diff));
if (asset.old.gzipSize || asset.new.gzipSize) {
lines.push(
`${toFileSizeDiff(asset.old.gzipSize, asset.new.gzipSize)} (gzip)`,
);
}
}
return lines.join('<br />');
}
function printAssetTableRow(asset) {
return [
asset.name,
toFileSizeDiffCell(asset),
conditionalPercentage(asset.diffPercentage),
].join(' | ');
}
function printAssetTablesByGroup(statsDiff) {
const statsFields = ['added', 'removed', 'bigger', 'smaller', 'unchanged'];
return statsFields
.map(field => {
const assets = statsDiff[field] ?? [];
if (assets.length === 0) {
return `**${capitalize(field)}**\nNo assets were ${field}`;
}
return `**${capitalize(field)}**\n${TABLE_HEADERS}\n${assets
.map(asset => printAssetTableRow(asset))
.join('\n')}`;
})
.join('\n\n');
}
function getDiffEmoji(diff) {
if (diff.diffPercentage === Infinity) return '🆕';
if (diff.diffPercentage <= -100) return '🔥';
if (diff.diffPercentage > 0) return '📈';
if (diff.diffPercentage < 0) return '📉';
return ' ';
}
function getTrimmedChunkName(chunkModule) {
const chunkName = chunkModule.name ?? '';
if (chunkName.startsWith('./')) {
return chunkName.substring(2);
}
if (chunkName.startsWith('/')) {
return chunkName.substring(1);
}
return chunkName;
}
function printChunkModuleRow(chunkModule) {
const emoji = getDiffEmoji(chunkModule);
const chunkName = getTrimmedChunkName(chunkModule);
const diffPart = `${chunkModule.diff >= 0 ? '+' : '-'}${formatFileSizeIEC(chunkModule.diff)}`;
const percentPart = Number.isFinite(chunkModule.diffPercentage)
? ` (${conditionalPercentage(chunkModule.diffPercentage)})`
: '';
return [
`\`${chunkName}\``,
`${emoji} ${diffPart}${percentPart}`,
`${formatFileSizeIEC(chunkModule.old.size)}${formatFileSizeIEC(chunkModule.new.size)}`,
].join(' | ');
}
function printChunkModulesTable(statsDiff) {
if (!statsDiff) {
return '';
}
const changedModules = [
...(statsDiff.added ?? []),
...(statsDiff.removed ?? []),
...(statsDiff.bigger ?? []),
...(statsDiff.smaller ?? []),
].sort((a, b) => b.diffPercentage - a.diffPercentage);
if (changedModules.length === 0) {
return `<details>\n<summary>Changeset</summary>\nNo files were changed\n</details>`;
}
const rows = changedModules
.slice(0, 100)
.map(chunkModule => printChunkModuleRow(chunkModule))
.join('\n');
const summarySuffix =
changedModules.length > 100 ? ' (largest 100 files by percent change)' : '';
return `<details>\n<summary>Changeset${summarySuffix}</summary>\n\n${CHUNK_TABLE_HEADERS}\n${rows}\n</details>`;
}
function printTotalAssetTable(statsDiff) {
return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`;
}
function renderSection(title, statsDiff, chunkModuleDiff) {
const { total, ...groups } = statsDiff;
const parts = [`#### ${title}`, '', printTotalAssetTable({ total })];
const chunkTable = printChunkModulesTable(chunkModuleDiff);
if (chunkTable) {
parts.push('', chunkTable);
}
parts.push(
'',
`<details>\n<summary>View detailed bundle breakdown</summary>\n<div>\n\n${printAssetTablesByGroup(
groups,
)}\n</div>\n</details>`,
);
return parts.join('\n');
}
async function main() {
const args = parseArgs(process.argv);
console.error(
`[bundle-stats] Found ${args.sections.length} sections to process`,
);
args.sections.forEach((section, index) => {
console.error(
`[bundle-stats] Section ${index + 1}: ${section.name} (base: ${section.basePath}, head: ${section.headPath})`,
);
});
const sections = [];
for (const section of args.sections) {
console.error(`[bundle-stats] Processing section: ${section.name}`);
console.error(
`[bundle-stats] Loading base stats from: ${section.basePath}`,
);
const baseStats = await loadStats(section.basePath);
console.error(
`[bundle-stats] Loading head stats from: ${section.headPath}`,
);
const headStats = await loadStats(section.headPath);
const statsDiff = getStatsDiff(baseStats, headStats);
const chunkDiff = getChunkModuleDiff(baseStats, headStats);
console.error(
`[bundle-stats] Section ${section.name}: ${statsDiff.total.name} files, total size ${statsDiff.total.old.size}${statsDiff.total.new.size}`,
);
sections.push({
name: section.name,
statsDiff,
chunkDiff,
});
}
const identifier = `<!--- bundlestats-action-comment key:${args.identifier} --->`;
const comment = [
'### Bundle Stats',
'',
sections
.map(section =>
renderSection(section.name, section.statsDiff, section.chunkDiff),
)
.join('\n\n---\n\n'),
'',
identifier,
'',
].join('\n');
process.stdout.write(comment);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env node
/**
* Updates (or creates) a bundle stats comment on a pull request.
* Requires the following environment variables to be set:
* - GITHUB_TOKEN
* - GITHUB_REPOSITORY (owner/repo)
* - PR_NUMBER
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { Octokit } from '@octokit/rest';
function parseArgs(argv) {
const args = {
commentFile: null,
identifier: null,
};
for (let i = 2; i < argv.length; i += 2) {
const key = argv[i];
const value = argv[i + 1];
if (!key?.startsWith('--')) {
throw new Error(
`Unexpected argument “${key ?? ''}”. Use --key value pairs.`,
);
}
if (typeof value === 'undefined') {
throw new Error(`Missing value for argument “${key}”.`);
}
switch (key) {
case '--comment-file':
args.commentFile = value;
break;
case '--identifier':
args.identifier = value;
break;
default:
throw new Error(`Unknown argument “${key}”.`);
}
}
if (!args.commentFile) {
throw new Error('Missing required argument “--comment-file“.');
}
if (!args.identifier) {
throw new Error('Missing required argument “--identifier“.');
}
return args;
}
async function loadCommentBody(commentFile) {
const absolutePath = path.resolve(process.cwd(), commentFile);
return readFile(absolutePath, 'utf8');
}
function getRepoInfo() {
const repository = process.env.GITHUB_REPOSITORY;
if (!repository) {
throw new Error('GITHUB_REPOSITORY environment variable is required.');
}
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY value “${repository}”.`);
}
return { owner, repo };
}
function getPullRequestNumber() {
const rawNumber = process.env.PR_NUMBER ?? '';
const prNumber = Number.parseInt(rawNumber, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(
'PR_NUMBER environment variable must be a positive integer.',
);
}
return prNumber;
}
function assertGitHubToken() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required.');
}
return token;
}
async function listComments(octokit, owner, repo, issueNumber) {
return octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
function isGitHubActionsBot(comment) {
return comment.user?.login === 'github-actions[bot]';
}
async function main() {
const { commentFile, identifier } = parseArgs(process.argv);
const commentBody = await loadCommentBody(commentFile);
const token = assertGitHubToken();
const { owner, repo } = getRepoInfo();
const issueNumber = getPullRequestNumber();
const octokit = new Octokit({ auth: token });
const comments = await listComments(octokit, owner, repo, issueNumber);
const existingComment = comments.find(
comment =>
isGitHubActionsBot(comment) && comment.body?.includes(identifier),
);
if (existingComment) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: commentBody,
});
console.log('Updated existing bundle stats comment.');
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody,
});
console.log('Created new bundle stats comment.');
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"devDependencies": {
"vitest": "^4.0.9"
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest --run"

View File

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

View File

@@ -13,10 +13,10 @@
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@types/react": "^19.2.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"vitest": "^4.0.9"
"vitest": "^3.2.4"
},
"exports": {
"./hooks/*": "./src/hooks/*.ts",
@@ -40,6 +40,7 @@
"./popover": "./src/Popover.tsx",
"./select": "./src/Select.tsx",
"./space-between": "./src/SpaceBetween.tsx",
"./stack": "./src/Stack.tsx",
"./styles": "./src/styles.ts",
"./text": "./src/Text.tsx",
"./text-one-line": "./src/TextOneLine.tsx",

View File

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

View File

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

View File

@@ -6,8 +6,6 @@ import { View } from './View';
type SpaceBetweenProps = {
direction?: 'horizontal' | 'vertical';
gap?: number;
wrap?: boolean;
align?: 'start' | 'center' | 'end' | 'stretch';
style?: CSSProperties;
children: ReactNode;
};
@@ -15,22 +13,18 @@ type SpaceBetweenProps = {
export const SpaceBetween = ({
direction = 'horizontal',
gap = 15,
wrap = true,
align = 'center',
style,
children,
...props
}: SpaceBetweenProps) => {
return (
<View
style={{
flexWrap: wrap ? 'wrap' : 'nowrap',
flexWrap: 'wrap',
flexDirection: direction === 'horizontal' ? 'row' : 'column',
alignItems: align,
alignItems: 'center',
gap,
...style,
}}
{...props}
>
{children}
</View>

View File

@@ -0,0 +1,105 @@
// @ts-strict-ignore
import React, {
Children,
type ComponentProps,
Fragment,
cloneElement,
forwardRef,
type ReactNode,
type CSSProperties,
} from 'react';
import { Text } from './Text';
import { View } from './View';
function getChildren(key, children) {
return Children.toArray(children).reduce(
(list, child) => {
if (child) {
if (
typeof child === 'object' &&
'type' in child &&
child.type === Fragment
) {
return list.concat(
getChildren(
child.key,
typeof child.props === 'object' && 'children' in child.props
? child.props.children
: [],
),
);
}
list.push({ key: key + child['key'], child });
return list;
}
return list;
},
[] as Array<{ key: string; child: ReactNode }>,
);
}
type StackProps = ComponentProps<typeof View> & {
direction?: CSSProperties['flexDirection'];
align?: string;
justify?: string;
spacing?: number;
debug?: boolean;
};
export const Stack = forwardRef<HTMLDivElement, StackProps>(
(
{
direction = 'column',
align,
justify,
spacing = 3,
children,
debug,
style,
...props
},
ref,
) => {
const isReversed = direction.endsWith('reverse');
const isHorizontal = direction.startsWith('row');
const validChildren = getChildren('', children);
return (
<View
style={{
flexDirection: direction,
alignItems: align,
justifyContent: justify,
...style,
}}
innerRef={ref}
{...props}
>
{validChildren.map(({ key, child }, index) => {
const isLastChild = validChildren.length === index + 1;
let marginProp;
if (isHorizontal) {
marginProp = isReversed ? 'marginLeft' : 'marginRight';
} else {
marginProp = isReversed ? 'marginTop' : 'marginBottom';
}
return cloneElement(
typeof child === 'string' ? <Text>{child}</Text> : child,
{
key,
style: {
...(debug && { borderWidth: 1, borderColor: 'red' }),
...(isLastChild ? null : { [marginProp]: spacing * 5 }),
...(child.props ? child.props.style : null),
},
},
);
})}
</View>
);
},
);
Stack.displayName = 'Stack';

View File

@@ -15,14 +15,12 @@ type TooltipProps = Partial<ComponentProps<typeof AriaTooltip>> & {
children: ReactNode;
content: ReactNode;
triggerProps?: Partial<ComponentProps<typeof TooltipTrigger>>;
disablePointerEvents?: boolean;
};
export const Tooltip = ({
children,
content,
triggerProps = {},
disablePointerEvents = false,
...props
}: TooltipProps) => {
const triggerRef = useRef(null);
@@ -71,14 +69,7 @@ export const Tooltip = ({
>
{children}
<AriaTooltip
triggerRef={triggerRef}
style={{
...styles.tooltip,
...(disablePointerEvents && { pointerEvents: 'none' }),
}}
{...props}
>
<AriaTooltip triggerRef={triggerRef} style={styles.tooltip} {...props}>
{content}
</AriaTooltip>
</TooltipTrigger>

View File

@@ -1,6 +1,7 @@
// @ts-strict-ignore
import path from 'path';
function indexTemplate(filePaths: { path: string }[]) {
function indexTemplate(filePaths) {
const exportEntries = filePaths.map(({ path: filePath }) => {
const basename = path.basename(filePath, path.extname(filePath));
const exportName = `Svg${basename}`;

View File

@@ -1,6 +1,5 @@
import { type Config } from '@svgr/core';
const tmpl: Config['template'] = ({ imports, interfaces, componentName, props, jsx }, { tpl }) => {
// @ts-strict-ignore
const tmpl = ({ imports, interfaces, componentName, props, jsx }, { tpl }) => {
return tpl`
${imports};

View File

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

View File

@@ -20,10 +20,10 @@
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-4",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.3",
"vitest": "^4.0.9"
"vitest": "^3.2.4"
}
}

View File

@@ -12,5 +12,5 @@
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": ["dist"]
}

View File

@@ -31,6 +31,3 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBankSyncPage } from './mobile-bank-sync-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobilePayeesPage } from './mobile-payees-page';
import { MobileReportsPage } from './mobile-reports-page';
@@ -16,7 +15,6 @@ const NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Schedules',
'Payees',
'Rules',
'Bank Sync',
'Settings',
];
const ROUTES_BY_PAGE = {
@@ -26,7 +24,6 @@ const ROUTES_BY_PAGE = {
Reports: '/reports',
Payees: '/payees',
Rules: '/rules',
'Bank Sync': '/bank-sync',
Settings: '/settings',
};
@@ -185,13 +182,6 @@ export class MobileNavigation {
);
}
async goToBankSyncPage() {
return await this.navigateToPage(
'Bank Sync',
() => new MobileBankSyncPage(this.page),
);
}
async goToSettingsPage() {
return await this.navigateToPage(
'Settings',

View File

@@ -51,7 +51,7 @@ export class MobilePayeesPage {
}
/**
* Click on a payee to open the edit page
* Click on a payee to view/edit rules
*/
async clickPayee(index: number) {
const payee = this.getNthPayee(index);

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ test.describe('Mobile Payees', () => {
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a payee opens payee edit page', async () => {
test('clicking on a payee opens rule creation form', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
@@ -70,16 +70,8 @@ test.describe('Mobile Payees', () => {
await payeesPage.clickPayee(0);
// Should navigate to payee edit page
await expect(page).toHaveURL(/\/payees\/.+/);
// Check that the edit page elements are visible
await expect(
page.getByRole('heading', { name: 'Edit Payee' }),
).toBeVisible();
await expect(page.getByPlaceholder('Payee name')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
// Should navigate to rules page for creating a new rule
await expect(page).toHaveURL(/\/rules/);
await expect(page).toMatchThemeScreenshots();
});

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