diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5f6fb98 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,tsx,js,jsx,cjs,mjs,json,yaml,yml,md}] +indent_style = space +indent_size = 2 + +[*.rs] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 819311e..028b430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,33 @@ on: - staging jobs: + format: + name: Formatting checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dashboard dependencies + working-directory: dashboard + run: npm ci + + - name: Check dashboard formatting (Prettier) + working-directory: dashboard + run: npm run format:check + + - name: Install listener dependencies + working-directory: listener + run: npm ci + + - name: Check listener formatting (Prettier) + working-directory: listener + run: npm run format:check + frontend: name: Frontend (lint, typecheck, test) runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..332bffd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +dashboard/node_modules/ +listener/node_modules/ + +# Build output +dist/ +dashboard/dist/ +listener/dist/ +target/ + +# Lock files (managed by package managers, not formatted) +package-lock.json +dashboard/package-lock.json +listener/package-lock.json +Cargo.lock + +# Generated / vendored +*.wasm +*.optimized.wasm +reports/ +dashboard/reports/ + +# Environment files +.env +.env.* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4d2523a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CODE_FORMATTING.md b/CODE_FORMATTING.md new file mode 100644 index 0000000..ac2edfd --- /dev/null +++ b/CODE_FORMATTING.md @@ -0,0 +1,103 @@ +# Code Formatting + +NotifyChain enforces consistent formatting across all languages via automated checks that run on every pull request. + +--- + +## Tools + +| Language | Tool | Config | +|---|---|---| +| TypeScript / TSX (dashboard) | [Prettier](https://prettier.io) 3.x | `.prettierrc` (root) | +| TypeScript (listener) | [Prettier](https://prettier.io) 3.x | `.prettierrc` (root) | +| Rust (contracts) | `rustfmt` (stable) | default `rustfmt` rules | +| All files | EditorConfig | `.editorconfig` (root) | + +--- + +## Prettier rules (TypeScript / TSX) + +Defined in `.prettierrc` at the repository root. + +| Rule | Value | +|---|---| +| `semi` | `true` — semicolons required | +| `singleQuote` | `true` — single quotes for strings | +| `trailingComma` | `"all"` — trailing commas wherever valid | +| `printWidth` | `100` — wrap lines at 100 characters | +| `tabWidth` | `2` — two-space indentation | +| `useTabs` | `false` — spaces, not tabs | +| `arrowParens` | `"always"` — parentheses around arrow function parameters | +| `endOfLine` | `"lf"` — Unix line endings | + +--- + +## Rust formatting + +The `rust` CI job runs `cargo fmt --all -- --check`. This uses the default `rustfmt` rules (stable channel). No custom `rustfmt.toml` is required. + +--- + +## EditorConfig + +`.editorconfig` enforces baseline rules in supported editors (VS Code, JetBrains, Vim, etc.) independently of any formatter: + +- UTF-8 encoding everywhere +- LF line endings everywhere +- Final newline on every file +- Trailing whitespace trimmed (except Markdown) +- 2-space indentation for TS/JS/JSON/YAML/Markdown +- 4-space indentation for Rust + +--- + +## CI enforcement + +The `format` job in `.github/workflows/ci.yml` runs on every pull request and push to `main` / `staging`: + +``` +format job + ├── dashboard: npm run format:check (Prettier --check) + └── listener: npm run format:check (Prettier --check) +``` + +The `rust` job also runs `cargo fmt --all -- --check`. + +A pull request **cannot be merged** if either check exits non-zero. + +--- + +## Fixing formatting locally + +### TypeScript / TSX + +```bash +# Fix dashboard +cd dashboard +npx prettier --write "src/**/*.{ts,tsx}" --config ../.prettierrc + +# Fix listener +cd listener +npx prettier --write "src/**/*.ts" --config ../.prettierrc +``` + +### Rust + +```bash +cd contract +cargo fmt --all +``` + +### VS Code auto-format on save + +Install the [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) extension and add to `.vscode/settings.json`: + +```json +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } +} +``` diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ca3faa6..14ee216 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -30,6 +30,7 @@ "jest-axe": "^10.0.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^26.1.0", + "prettier": "3.3.3", "ts-jest": "^29.2.5", "typescript": "^5.8.3", "vite": "^6.3.5" @@ -16202,6 +16203,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index b0433d9..ac5d8c7 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,6 +8,7 @@ "preview": "node ./node_modules/vite/bin/vite.js preview", "dev": "node ./node_modules/vite/bin/vite.js", "lint": "node ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\" --max-warnings=0", + "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.{ts,tsx}\" --config ../.prettierrc", "test": "node ./node_modules/jest/bin/jest.js", "test:wallet": "node ./node_modules/jest/bin/jest.js src/__tests__/wallet-integration.test.tsx", "benchmark": "node ./node_modules/jest/bin/jest.js src/benchmark" @@ -19,13 +20,6 @@ "zustand": "^5.0.6" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^6.10.0", - "@typescript-eslint/parser": "^6.10.0", - "eslint": "^8.46.0", - "eslint-plugin-react": "^7.33.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/jest": "^29.5.14", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -33,11 +27,16 @@ "@types/jest-axe": "^3.5.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.7.0", + "eslint": "^8.46.0", + "eslint-plugin-react": "^7.33.0", "jest": "^29.7.0", "jest-axe": "^10.0.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^26.1.0", + "prettier": "3.3.3", "ts-jest": "^29.2.5", "typescript": "^5.8.3", "vite": "^6.3.5" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 792f057..8e2dd1d 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -129,6 +129,82 @@ export function App() { + + + {tab === 'explorer' && ( export function App() { const [tab, setTab] = useState('explorer'); const [drawerOpen, setDrawerOpen] = useState(false); diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx index 18ea4fe..dcccf6f 100644 --- a/dashboard/src/__tests__/wallet-integration.test.tsx +++ b/dashboard/src/__tests__/wallet-integration.test.tsx @@ -10,7 +10,6 @@ import * as fs from 'fs'; import * as path from 'path'; const WALLET_ID_KEY = 'notify-chain:wallet-id'; -const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; const REPORT_PATH = path.join(process.cwd(), 'reports', 'wallet-integration.json'); type KitMock = typeof import('../test/stellarWalletsKitMock'); @@ -239,7 +238,7 @@ describe('Notification feed clears on wallet switch (issue #175)', () => { await wallet.disconnectWallet(); expect(store.useWalletStore.getState().address).toBeNull(); - expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull(); + expect(localStorage.getItem('notify-chain:wallet-address')).toBeNull(); }); it('no stale address remains in localStorage after switching wallets', async () => { @@ -258,8 +257,8 @@ describe('Notification feed clears on wallet switch (issue #175)', () => { await wallet.connectWallet(); // localStorage must reflect the new account only - expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(SUPPORTED_WALLETS[2].address); - expect(localStorage.getItem(WALLET_ADDRESS_KEY)).not.toBe(SUPPORTED_WALLETS[0].address); + expect(localStorage.getItem('notify-chain:wallet-address')).toBe(SUPPORTED_WALLETS[2].address); + expect(localStorage.getItem('notify-chain:wallet-address')).not.toBe(SUPPORTED_WALLETS[0].address); expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[2].address); }); @@ -283,7 +282,7 @@ describe('Notification feed clears on wallet switch (issue #175)', () => { await wallet.connectWallet(); expect(store.useWalletStore.getState().address).toBe(provider.address); - expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe(provider.address); + expect(localStorage.getItem('notify-chain:wallet-address')).toBe(provider.address); expect(localStorage.getItem(WALLET_ID_KEY)).toBe(provider.id); localStorage.clear(); diff --git a/dashboard/src/hooks/useWalletAccountSync.ts b/dashboard/src/hooks/useWalletAccountSync.ts index fe339cb..170621b 100644 --- a/dashboard/src/hooks/useWalletAccountSync.ts +++ b/dashboard/src/hooks/useWalletAccountSync.ts @@ -9,7 +9,7 @@ import { useWalletStore } from '../store/walletStore'; * its initial value) — it fires only for subsequent transitions, i.e. a real * wallet switch or disconnect while the page is open. */ -export function useWalletAccountSync(onAccountChange: (address: string | null) => void): void { +export function useWalletAccountSync(onAccountChange: () => void): void { const address = useWalletStore((state) => state.address); // Track whether this is the very first render so we can skip it. @@ -24,6 +24,6 @@ export function useWalletAccountSync(onAccountChange: (address: string | null) = isFirstRender.current = false; return; } - callbackRef.current(address); + callbackRef.current(); }, [address]); } diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx index 1221df4..01be2bd 100644 --- a/dashboard/src/pages/NotificationSearchPage.test.tsx +++ b/dashboard/src/pages/NotificationSearchPage.test.tsx @@ -1,9 +1,16 @@ import '@testing-library/jest-dom'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { NotificationSearchPage } from './NotificationSearchPage'; import { searchNotifications } from '../services/eventsApi'; import type { NotificationSearchResponse } from '../services/eventsApi'; +jest.mock('../services/eventsApi', () => ({ + searchNotifications: jest.fn(), +})); + +const mockedSearch = searchNotifications as jest.MockedFunction; + jest.mock('../services/eventsApi', () => { const actual = jest.requireActual('../services/eventsApi') as typeof import('../services/eventsApi'); return { @@ -47,6 +54,18 @@ const mockResult: NotificationSearchResponse = { totalPages: 1, }; +function emptyResponse(): NotificationSearchResponse { + return { + results: [], + total: 0, + limit: 20, + offset: 0, + itemCount: 0, + totalPages: 0, + }; +} + +describe('NotificationSearchPage loading skeletons', () => { describe('NotificationSearchPage filters', () => { beforeEach(() => { jest.useFakeTimers(); @@ -166,7 +185,6 @@ describe('NotificationSearchPage filters', () => { expect(screen.getByLabelText(/filter from date/i)).toHaveValue(''); expect(screen.queryByRole('button', { name: /clear all filters/i })).not.toBeInTheDocument(); }); -}); describe('searchNotifications query params', () => { const originalFetch = global.fetch; @@ -271,6 +289,13 @@ describe('NotificationSearchPage loading skeletons', () => { }); }); +describe('searchNotifications query params', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => emptyResponse(), describe('NotificationResultCard copy notification ID', () => { beforeEach(() => { mockedSearch.mockReset(); @@ -283,6 +308,28 @@ describe('NotificationResultCard copy notification ID', () => { }); afterEach(() => { + global.fetch = originalFetch; + }); + + it('appends type, status, startDate, and endDate to the URL', async () => { + const { searchNotifications: realSearch } = jest.requireActual( + '../services/eventsApi' + ) as typeof import('../services/eventsApi'); + + await realSearch('http://localhost:8787', { + type: 'webhook', + status: 'COMPLETED', + startDate: '2026-01-01', + endDate: '2026-01-31', + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('type=webhook') + ); + const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain('status=COMPLETED'); + expect(calledUrl).toContain('startDate=2026-01-01'); + expect(calledUrl).toContain('endDate=2026-01-31'); jest.useRealTimers(); }); diff --git a/listener/package-lock.json b/listener/package-lock.json index efacc89..19bf08d 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -27,6 +27,7 @@ "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.46.0", "jest": "^29.7.0", + "prettier": "3.3.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "~5.4.5" @@ -2841,6 +2842,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "version": "2.11.4", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", @@ -7301,6 +7305,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/listener/package.json b/listener/package.json index 498caf2..683d2a1 100644 --- a/listener/package.json +++ b/listener/package.json @@ -8,6 +8,7 @@ "start": "node dist/index.js", "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", "lint": "node ./node_modules/typescript/bin/tsc --noEmit", + "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" --config ../.prettierrc", "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", "migrate:templates": "ts-node src/scripts/migrate-templates.ts", @@ -37,6 +38,7 @@ "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.46.0", "jest": "^29.7.0", + "prettier": "3.3.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "~5.4.5"