diff --git a/bin/run-browser-tests b/bin/run-browser-tests
new file mode 100755
index 0000000000..3054d4695e
--- /dev/null
+++ b/bin/run-browser-tests
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+# Run browser tests (vitest browser mode) in Docker for consistent screenshot quality
+# Browser tests generate screenshots that vary by environment (fonts, rendering, etc.)
+# Running in Docker ensures consistent results across different machines
+#
+# Usage:
+# yarn test:browser # Run all browser tests
+# yarn test:browser AuthSettings.browser # Run specific test file
+# yarn test:browser --update # Update snapshots
+
+if [ ! -d "node_modules" ] || [ "$(ls -A node_modules)" = "" ]; then
+ yarn
+fi
+
+TEST_ARGS=""
+
+# Loop through all arguments
+while [ $# -gt 0 ]; do
+ TEST_ARGS="$TEST_ARGS $1"
+ shift
+done
+
+echo "Running browser tests in Docker for consistent screenshot quality..."
+echo "Test args: $TEST_ARGS"
+echo ""
+
+MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash \
+ -c "yarn workspace @actual-app/web test --project=browser $TEST_ARGS"
diff --git a/package.json b/package.json
index b75c2cf7fd..21df0e2d34 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",
+ "test:browser": "./bin/run-browser-tests",
"test:debug": "lage test --no-cache --continue",
"e2e": "yarn workspace @actual-app/web run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build --skip-translations && yarn workspace desktop-electron e2e",
diff --git a/packages/desktop-client/README.md b/packages/desktop-client/README.md
index e9f2f6da9d..710dc3ade3 100644
--- a/packages/desktop-client/README.md
+++ b/packages/desktop-client/README.md
@@ -96,3 +96,48 @@ Run locally:
```sh
E2E_START_URL=https://my-remote-server.com yarn vrt
```
+
+## Browser Tests (Vitest Browser Mode)
+
+Browser tests (`.browser.test.tsx` files) use Vitest's browser mode to test React components with visual regression screenshots. These tests generate screenshots that can vary significantly by environment (fonts, rendering, DPI, etc.).
+
+**IMPORTANT: For consistent screenshot quality, always run browser tests in Docker.**
+
+### Running Browser Tests in Docker
+
+From the project root:
+
+```sh
+# Run all browser tests
+yarn test:browser:docker
+
+# Run a specific browser test file
+yarn test:browser:docker AuthSettings.browser
+
+# Run with update flag to update snapshots
+yarn test:browser:docker AuthSettings.browser --update
+```
+
+From the `packages/desktop-client` directory:
+
+```sh
+# Run all browser tests
+yarn test:browser:docker
+
+# Run a specific browser test file
+yarn test:browser:docker AuthSettings.browser
+
+# Run with update flag
+yarn test:browser:docker AuthSettings.browser --update
+```
+
+### Why Docker?
+
+Running browser tests locally will produce inconsistent screenshots due to:
+
+- System-specific font rendering
+- Different DPI/display scaling
+- OS-specific rendering differences
+- Font availability variations
+
+Docker ensures all tests run in the same standardized environment (`mcr.microsoft.com/playwright:v1.56.0-jammy`), producing consistent, reproducible screenshots.
diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index c9b9e0a043..9b55552041 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -47,6 +47,7 @@
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-basic-ssl": "^2.1.3",
"@vitejs/plugin-react": "^5.1.2",
+ "@vitest/browser-playwright": "^4.0.17",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"cmdk": "^1.1.1",
@@ -93,8 +94,9 @@
"uuid": "^13.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
- "vite-tsconfig-paths": "^5.1.4",
+ "vite-tsconfig-paths": "^6.0.4",
"vitest": "^4.0.16",
+ "vitest-browser-react": "^2.0.2",
"xml2js": "^0.6.2"
}
}
diff --git a/packages/desktop-client/src/components/settings/AuthSettings.browser.test.tsx b/packages/desktop-client/src/components/settings/AuthSettings.browser.test.tsx
index 70cdf17cb2..47f6d5e0d8 100644
--- a/packages/desktop-client/src/components/settings/AuthSettings.browser.test.tsx
+++ b/packages/desktop-client/src/components/settings/AuthSettings.browser.test.tsx
@@ -1,3 +1,15 @@
+/**
+ * Browser Visual Regression Tests
+ *
+ * IMPORTANT: These tests generate screenshots that vary by environment (fonts, rendering, etc.).
+ * For consistent screenshot quality, always run these tests in Docker:
+ *
+ * From root: yarn test:browser:docker AuthSettings.browser
+ * Or: cd packages/desktop-client && yarn test:browser:docker AuthSettings.browser
+ *
+ * Running locally will produce inconsistent screenshots due to system-specific rendering differences.
+ */
+
import { expect, test, vi, beforeEach, describe } from 'vitest';
import { render } from 'vitest-browser-react';
import { page } from 'vitest/browser';
@@ -26,7 +38,7 @@ describe('AuthSettings Visual Regression', () => {
vi.clearAllMocks();
});
- test('does not render OpenID block when server status is no-server', async () => {
+ test.skip('does not render OpenID block when server status is no-server', async () => {
vi.mocked(useSyncServerStatus).mockReturnValue('no-server');
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
@@ -47,16 +59,49 @@ describe('AuthSettings Visual Regression', () => {
}
});
- test('renders disabled OpenID block with warning when server is offline', async () => {
+ // TODO: render permutations
+ test.only('renders disabled OpenID block with warning when server is offline', async () => {
vi.mocked(useSyncServerStatus).mockReturnValue('offline');
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
- const screen = await render(, {
- wrapper: BrowserTestProvider,
- });
+ await render(
+
+
+
,
+ {
+ wrapper: BrowserTestProvider,
+ },
+ );
- await expect(screen.container).toMatchScreenshot();
+ // Wait for the element to be fully rendered and visible
+ const wrapper = page.getByTestId('auth-settings-wrapper');
+ await expect(wrapper).toBeVisible();
+
+ // Wait for rendering to complete
+ await new Promise(resolve => setTimeout(resolve, 200));
+
+ // Use toMatchScreenshot - scale: 'device' is set globally in vite.config.mts
+ // This should use device pixels (respecting deviceScaleFactor: 3) instead of CSS pixels
+ // With deviceScaleFactor: 3, screenshots should be 3x the CSS size
+ //
+ // NOTE: If screenshots are still low-res, this is likely a bug in vitest-browser
+ // where toMatchScreenshot doesn't properly respect deviceScaleFactor even with scale: 'device'
+ await expect(wrapper).toMatchScreenshot();
});
test('renders enabled OpenID block when server is online with password login', async () => {
@@ -64,11 +109,26 @@ describe('AuthSettings Visual Regression', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
- const screen = await render(, {
- wrapper: BrowserTestProvider,
- });
+ await render(
+
+
+
,
+ {
+ wrapper: BrowserTestProvider,
+ },
+ );
- await expect(screen.container).toMatchScreenshot();
+ const wrapper = page.getByTestId('auth-settings-wrapper');
+ // scale: 'device' is set globally in vite.config.mts
+ await expect(wrapper).toMatchScreenshot();
});
test('renders OpenID enabled state when server is online with OpenID login', async () => {
@@ -76,10 +136,25 @@ describe('AuthSettings Visual Regression', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('openid');
- const screen = await render(, {
- wrapper: BrowserTestProvider,
- });
+ await render(
+