Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/types.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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`).
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion test/bytes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion test/model/Errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
9 changes: 8 additions & 1 deletion test/model/MintInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 6 additions & 10 deletions test/transport/request.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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) }),
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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/**"]
}
1 change: 0 additions & 1 deletion test/wallet/WalletOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions test/wallet/_setup.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion test/wallet/wallet-melt.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions test/wallet/wallet-mint.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 10 additions & 3 deletions test/wallet/wallet-quotes-mutants.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'");
});
});
Expand Down
11 changes: 9 additions & 2 deletions test/wallet/wallet-send.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
Loading