diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml new file mode 100644 index 000000000..f13f505ab --- /dev/null +++ b/.github/workflows/types.yml @@ -0,0 +1,31 @@ +name: Type Check + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Check types + run: npm run check-types diff --git a/.husky/pre-push b/.husky/pre-push index 59bee3b01..9a3e019bf 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,13 +1,13 @@ #!/usr/bin/env sh -echo "Running pre-push: lint + format checks" +echo "Running pre-push: lint + format + type checks" if ! command -v npm >/dev/null 2>&1; then echo "npm not found in PATH. Install Node.js/npm before pushing." >&2 exit 1 fi -if ! npm run check-lint || ! npm run check-format; then +if ! npm run check-lint || ! npm run check-format || ! npm run check-types; then echo "Pre-push checks failed. Push aborted." >&2 exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index dc9729cae..ae5bc0447 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,8 @@ Local installs configure Husky hooks automatically. ## Common tasks -CI check: `npm run prtasks` (lint, format, api:update, tests). -Repo-wide lint checks: `npm run check-lint` and `npm run check-format`. +CI check: `npm run prtasks` (lint, format, types, api:update, tests). +Repo-wide checks: `npm run check-lint`, `npm run check-format` and `npm run check-types`. Integration tests: `npm run test-integration` (requires local mint, see below). Consumer smoke tests: `npm run test:consumer`. Other scripts: see `package.json`. @@ -98,6 +98,7 @@ DEV=1 make nutshell-stable-down ## Linting and TypeScript rules - Lint config lives in `eslint.config.js` (flat config). +- `npm run check-types` type checks the test tree (and, via its imports, `src/`). Vitest strips types without checking them, so this is the only thing that catches fixtures drifting from the library types. - `any` is not allowed (prefer explicit types, generics, or `unknown` with narrowing). - Type-only imports/exports are required (`@typescript-eslint/consistent-type-imports`). - No Node-only modules in library code (`import/no-nodejs-modules`). @@ -111,7 +112,7 @@ Hooks are installed by Husky: - `commit-msg` enforces Conventional Commits. - `pre-commit` runs `lint-staged` on staged files. -- `pre-push` runs `npm run check-lint` and `npm run check-format`. +- `pre-push` runs `npm run check-lint`, `npm run check-format` and `npm run check-types`. ## If you are making changes (author flow) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f072881f0..468f39af1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Husky hooks install automatically during local `npm ci` / `npm install` via the - `commit-msg` enforces Conventional Commits. - `pre-commit` runs `lint-staged` on staged files. For `*.{js,ts}` it runs ESLint with `--fix` and Prettier; for `*.{json,md,yml,yaml}` it runs Prettier. -- `pre-push` runs repository-wide `npm run check-lint` and `npm run check-format`. +- `pre-push` runs repository-wide `npm run check-lint`, `npm run check-format` and `npm run check-types`. If you want more detail on the hook behavior, see the `Hooks` section in `DEVELOPER.md`. @@ -58,7 +58,7 @@ We also have an "all-in-one" script that runs all CI tasks sequentially: npm run prtasks ``` -Tip: if you want to mirror the pre-push checks manually, run `npm run check-lint` and `npm run check-format`. +Tip: if you want to mirror the pre-push checks manually, run `npm run check-lint`, `npm run check-format` and `npm run check-types`. ## API Extractor diff --git a/package.json b/package.json index d1f4dca25..65b630b5a 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,13 @@ "check-lint": "eslint '**/*.{js,ts}'", "format": "prettier --write .", "check-format": "prettier --check .", + "check-types": "tsc -p test/tsconfig.json", "typedoc": "typedoc", "api:check": "npm run compile && api-extractor run", "api:update": "npm run compile && api-extractor run --local", "setup-hooks": "chmod +x scripts/install-git-hooks.sh && ./scripts/install-git-hooks.sh", "uninstall-hooks": "git config --local --unset core.hooksPath && [ \"${REMOVE_GITHOOKS:-0}\" = \"1\" ] && rm -rf .githooks || echo 'To remove the installed hooks set REMOVE_GITHOOKS=1'", - "prtasks": "npm run lint && npm run format && npm run api:update && npm run test && git status", + "prtasks": "npm run lint && npm run format && npm run check-types && npm run api:update && npm run test && git status", "prepare": "husky && [ \"$CI\" != \"true\" ] && npm run setup-git || true", "setup-git": "git config commit.template .gitmessage", "migrate-hooks": "REMOVE_GITHOOKS=1 npm run uninstall-hooks && npm install", diff --git a/test/bytes.test.ts b/test/bytes.test.ts index 672ab5dce..fedefb010 100644 --- a/test/bytes.test.ts +++ b/test/bytes.test.ts @@ -323,7 +323,7 @@ describe('Bytes utility class', () => { test('large arrays produce valid base64 on the btoa fallback path (no Buffer)', () => { // Force the chunked btoa path that browsers use; harmless where Buffer // is already absent. - const g = globalThis as typeof globalThis & { Buffer?: unknown }; + const g = globalThis as unknown as { Buffer?: unknown }; const originalBuffer = g.Buffer; try { g.Buffer = undefined; diff --git a/test/model/Errors.test.ts b/test/model/Errors.test.ts index 521433831..609406689 100644 --- a/test/model/Errors.test.ts +++ b/test/model/Errors.test.ts @@ -43,7 +43,8 @@ describe('CTSError', () => { test('cause is writable and reassignable', () => { const err = new CTSError('boom', { cause: new Error('first') }); const replacement = new Error('second'); - err.cause = replacement; + // `cause` is declared readonly but defined as writable at runtime. + (err as { cause?: unknown }).cause = replacement; expect(err.cause).toBe(replacement); }); diff --git a/test/model/MintInfo.test.ts b/test/model/MintInfo.test.ts index b1b47437e..caba51f7b 100644 --- a/test/model/MintInfo.test.ts +++ b/test/model/MintInfo.test.ts @@ -704,7 +704,14 @@ describe('MintInfo method/unit capability checks', () => { describe('MintInfo list caps', () => { function spyLogger() { - return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), trace: vi.fn() }; + return { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + log: vi.fn(), + }; } it('truncates an oversized NUT-04 method list and warns', () => { diff --git a/test/transport/request.node.test.ts b/test/transport/request.node.test.ts index 2756b11a4..73397e05c 100644 --- a/test/transport/request.node.test.ts +++ b/test/transport/request.node.test.ts @@ -210,7 +210,7 @@ describe('requests', { timeout: 7500 }, () => { const thrown = await request({ endpoint }).catch((e) => e); expect(thrown).toBeInstanceOf(HttpResponseError); expect(thrown).toMatchObject({ message: 'bad response', status: 200 }); - expect(thrown.cause).toMatchObject({ message: 'Empty response body' }); + expect((thrown as HttpResponseError).cause).toMatchObject({ message: 'Empty response body' }); }); test('maps malformed success JSON to bad response and logs parsing failure', async () => { @@ -235,7 +235,7 @@ describe('requests', { timeout: 7500 }, () => { const thrown = await request({ endpoint }).catch((e) => e); expect(thrown).toBeInstanceOf(HttpResponseError); expect(thrown).toMatchObject({ message: 'bad response' }); - expect(thrown.cause).toBeInstanceOf(Error); + expect((thrown as HttpResponseError).cause).toBeInstanceOf(Error); expect(logger.error).toHaveBeenCalledWith( 'Failed to parse HTTP response', expect.objectContaining({ err: expect.any(Error) }), @@ -257,7 +257,7 @@ describe('requests', { timeout: 7500 }, () => { const thrown = await request({ endpoint }).catch((e) => e); expect(thrown).toBeInstanceOf(NetworkError); expect(thrown).toMatchObject({ message: 'aborted by runtime' }); - expect(thrown.cause).toBe(abortError); + expect((thrown as NetworkError).cause).toBe(abortError); } finally { fetchMock.mockRestore(); } @@ -279,7 +279,7 @@ describe('requests', { timeout: 7500 }, () => { const thrown = await request({ endpoint }).catch((e) => e); expect(thrown).toBeInstanceOf(HttpResponseError); expect(thrown).toMatchObject({ message: 'bad response', status: 503 }); - expect(thrown.cause).toBe(bodyReadError); + expect((thrown as HttpResponseError).cause).toBe(bodyReadError); } finally { fetchMock.mockRestore(); } @@ -992,9 +992,7 @@ describe('response body read timeout', () => { test('requestTimeout covers a hung success body that ignores the signal', async () => { hungBody(200); - const thrown = await request({ endpoint, requestTimeout: 100, idempotent: false }).catch( - (e) => e, - ); + const thrown = await request({ endpoint, requestTimeout: 100 }).catch((e) => e); expect(thrown).toBeInstanceOf(NetworkError); expect((thrown as Error).message).toContain('Request timed out after 100ms'); }, 2000); @@ -1010,9 +1008,7 @@ describe('response body read timeout', () => { test('timeout during a hung error body maps to NetworkError, not a 5xx', async () => { hungBody(500); - const thrown = await request({ endpoint, requestTimeout: 100, idempotent: false }).catch( - (e) => e, - ); + const thrown = await request({ endpoint, requestTimeout: 100 }).catch((e) => e); expect(thrown).toBeInstanceOf(NetworkError); expect((thrown as Error).message).toContain('Request timed out after 100ms'); }, 2000); diff --git a/test/tsconfig.json b/test/tsconfig.json index 53bdf4e6b..ef85c8cc3 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,6 +1,19 @@ { // Gives eslint's typed rules (projectService) a project covering test/ — - // the root tsconfig only includes src/. Not used by builds or vitest. + // the root tsconfig only includes src/. Also drives `npm run check-types`, + // which type checks test/ and, via its imports, src/. Not used by builds + // or vitest (vitest strips types without checking them). "extends": "../tsconfig.json", - "include": ["**/*"] + "compilerOptions": { + // Tests import from ../src, so the project root sits above test/. + "rootDir": "..", + "noEmit": true, + "declaration": false, + "emitDeclarationOnly": false + }, + "include": ["**/*"], + // Consumer smoke harnesses import the built package by its published name, so + // they only resolve after a build. They have their own workflow (consumers.yml) + // and eslint skips them for the same reason. + "exclude": ["consumer/**"] } diff --git a/test/wallet/WalletOps.test.ts b/test/wallet/WalletOps.test.ts index 4a8e52569..d7afd468c 100644 --- a/test/wallet/WalletOps.test.ts +++ b/test/wallet/WalletOps.test.ts @@ -254,7 +254,6 @@ const meltOnchainMulti: MeltQuoteOnchainResponse = { const mintOnchain: MintQuoteOnchainResponse = { quote: 'mq-onchain', request: 'bc1qdeposit', - amount: Amount.from(8), unit: 'sat', expiry: null, pubkey: '0200000', diff --git a/test/wallet/_setup.ts b/test/wallet/_setup.ts index 53895f487..d44da5612 100644 --- a/test/wallet/_setup.ts +++ b/test/wallet/_setup.ts @@ -1,6 +1,6 @@ import { WebSocket } from 'mock-socket'; import { HttpResponse, http } from 'msw'; -import { setupServer, type SetupServerApi } from 'msw/node'; +import { setupServer, type SetupServer } from 'msw/node'; import { beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { Mint, ConsoleLogger, injectWebSocketImpl } from '../../src'; @@ -27,7 +27,7 @@ export const token3sat = 'cashuBo2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdGF0gaJhaUgAvQM1Wd4n0GFwgqNhYQFhc3hAZTdjMWI3NmQxYjMxZTJiY2EyYjIyOWQxNjBiZGY2MDQ2ZjMzYmM0NTcwMjIyMzA0YjY1MTEwZDkyNmY3YWY4OWFjWCEDic2fT5iOOAp5idTUiKfJHFJ3-5MEfnoswe2OM5a4VP-jYWECYXN4QGRlNTVjMTVmYWVmZGVkN2Y5Yzk5OWMzZDRjNjJmODFiMGM2ZmUyMWE3NTJmZGVmZjZiMDg0Y2YyZGYyZjVjZjNhY1ghAt5AxZ2QODuIU8zzpLIIZKyDunWPzj2VnbuJNhAC6M5H'; export const logger = new ConsoleLogger('debug'); -export function setupDefaultHandlers(server: SetupServerApi) { +export function setupDefaultHandlers(server: SetupServer) { server.use( http.get(mintUrl + '/v1/info', () => { return HttpResponse.json(mintInfoResp); diff --git a/test/wallet/wallet-melt.node.test.ts b/test/wallet/wallet-melt.node.test.ts index 92424b46f..eabf181cc 100644 --- a/test/wallet/wallet-melt.node.test.ts +++ b/test/wallet/wallet-melt.node.test.ts @@ -895,7 +895,7 @@ describe('async melt preference body', () => { const debug = vi.fn(); const wallet = new Wallet(mint, { unit, - logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug, trace: vi.fn() }, + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug, trace: vi.fn(), log: vi.fn() }, }); await wallet.loadMint(); const meltTxn = await wallet.prepareMelt('bolt11', meltQuote, proofs); diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index b8cde2075..c74cd58da 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -1383,7 +1383,6 @@ describe('generic mint/melt methods', () => { amount: null, amount_paid: Amount.from(0), amount_issued: Amount.from(0), - state: MintQuoteState.UNPAID, expiry: null, pubkey: '02a1', }, @@ -1641,7 +1640,6 @@ describe('generic mint/melt methods', () => { unit: 'sat', amount: Amount.from(5), pubkey: '02f01fd65b16d80f7eff6ef2e0b3c5a8028b745796bbdc06cb503022262b2ebb51', - state: MintQuoteState.PAID, expiry: null, amount_paid: Amount.from(5), amount_issued: Amount.from(3), diff --git a/test/wallet/wallet-quotes-mutants.node.test.ts b/test/wallet/wallet-quotes-mutants.node.test.ts index c973f70be..5e5f98882 100644 --- a/test/wallet/wallet-quotes-mutants.node.test.ts +++ b/test/wallet/wallet-quotes-mutants.node.test.ts @@ -64,7 +64,14 @@ describe('constructor mutants', () => { // failIf logs its context before throwing, so the value must not appear there. const mnemonic = 'abandon abandon abandon abandon about'; const error = vi.fn(); - const logger = { error, warn: vi.fn(), info: vi.fn(), debug: vi.fn(), trace: vi.fn() }; + const logger = { + error, + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + log: vi.fn(), + }; expect(() => new Wallet(mint, { unit, logger, bip39seed: mnemonic as never })).toThrow(); @@ -1019,7 +1026,7 @@ describe('createLockedMintQuote mutants', () => { await wallet.loadMint(); const quote = await wallet.createLockedMintQuote(100, PUBKEY); - expect(quote.pubkey.toLowerCase()).toBe(PUBKEY); + expect(quote.pubkey!.toLowerCase()).toBe(PUBKEY); }); test('rejects a missing pubkey with a clear error, not a TypeError', async () => { @@ -1120,7 +1127,7 @@ describe('mintProofsBolt11 mutants', () => { // A wrong-unit quote object must be rejected by validateMintQuote. A mutant that always // takes the string-id branch would skip validation and fail later with a different error. await expect( - wallet.mintProofsBolt11(1, { quote: 'x', unit: 'usd' } as MintQuoteBolt11Response, []), + wallet.mintProofsBolt11(1, { quote: 'x', unit: 'usd' } as MintQuoteBolt11Response), ).rejects.toThrow("Quote unit 'usd' does not match wallet unit 'sat'"); }); }); diff --git a/test/wallet/wallet-send.node.test.ts b/test/wallet/wallet-send.node.test.ts index edd947e8b..762c5f3fd 100644 --- a/test/wallet/wallet-send.node.test.ts +++ b/test/wallet/wallet-send.node.test.ts @@ -1088,7 +1088,14 @@ describe('send', () => { // Two wallets on one shared source: the documented multi-wallet pattern. const counterSource = createEphemeralCounterSource(); const warn = vi.fn(); - const spyLogger = { error: vi.fn(), warn, info: vi.fn(), debug: vi.fn(), trace: vi.fn() }; + const spyLogger = { + error: vi.fn(), + warn, + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + log: vi.fn(), + }; const autoWallet = new Wallet(mint, { unit, bip39seed: seed, counterSource }); const manualWallet = new Wallet(mint, { unit, @@ -1161,7 +1168,7 @@ describe('send', () => { unit, bip39seed: seed, counterSource: legacySource, - logger: { error: vi.fn(), warn, info: vi.fn(), debug: vi.fn(), trace: vi.fn() }, + logger: { error: vi.fn(), warn, info: vi.fn(), debug: vi.fn(), trace: vi.fn(), log: vi.fn() }, }); await wallet.loadMint();