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
72 changes: 72 additions & 0 deletions .github/workflows/integration-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Integration tests (nightly)

# Runs the real-testnet integration suite on a nightly schedule. Excluded
# from the default push/PR CI because these tests:
# - hit public Nostr relay, aggregator, and IPFS endpoints (shared infra,
# not our own — we don't want to hammer them on every push)
# - take minutes to run (wallet bootstrap + real DM round-trips)
# - can flake on transient relay/aggregator hiccups
#
# Triggers:
# - schedule: 03:07 UTC nightly (odd minute to avoid the :00 traffic spike)
# - workflow_dispatch: manual trigger from the Actions tab for on-demand runs
#
# Failures surface in the Actions tab but do NOT block PR merges.

on:
schedule:
# 03:07 UTC every day
- cron: '7 3 * * *'
workflow_dispatch:

jobs:
integration:
name: integration (testnet)
runs-on: ubuntu-latest
# Single Node version — integration tests are slow and exercise the SDK,
# not Node-version-specific behaviour. Unit-test matrix stays in ci.yml.
steps:
- uses: actions/checkout@v4

- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm

# See ci.yml for the rationale behind the sibling-clone workaround.
# Kept identical here so a nightly run is hermetic w.r.t. ci.yml state.
- name: Clone sphere-sdk sibling
run: |
git clone --depth 1 --branch refactor/extract-cli-to-sphere-cli \
https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk

- name: Build sphere-sdk (required for file: dependency to resolve types)
run: |
cd ../../sphere-sdk
npm ci
npm run build

- run: npm ci

# The integration script builds the CLI itself (test:integration =
# `npm run build && vitest run --config vitest.integration.config.ts`),
# so no explicit build step here.
- name: Run integration tests
run: npm run test:integration
# Total suite ~5 min worst case; 20 min is generous headroom for
# a slow testnet day without leaving a hung job indefinitely.
timeout-minutes: 20

# Upload the tmp wallet dirs + logs on failure so a flake is debuggable
# without re-running. Path covers the vitest test-timeout stderr spew
# plus anything the integration helpers leave in os.tmpdir().
- name: Collect logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: integration-logs-${{ github.run_id }}
path: |
/tmp/sphere-cli-it-*/
retention-days: 7
if-no-files-found: ignore
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ coverage/
*.tsbuildinfo
.vscode/
.idea/
.claude/
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,22 @@ sphere host spawn --manager @myhostmanager --template tpl-1 mybot
npm ci
npm run build
npm test
npm run check # lint + typecheck + test
npm run check # lint + typecheck + unit tests
npm run test:integration # end-to-end tests against real public testnet
```

### Integration tests

The `test/integration/` suite exercises the built CLI against real public
infrastructure:

- Nostr relay — `wss://nostr-relay.testnet.unicity.network`
- Aggregator — `https://goggregator-test.unicity.network`
- IPFS gateway — `https://unicity-ipfs1.dyndns.org`

Each test creates a throwaway wallet in `/tmp` so runs are fully isolated and
never touch real funds. Skip with `SKIP_INTEGRATION=1` when offline.

## Design principles

1. **DM-native.** All controller → manager and controller → tenant traffic goes
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:integration": "npm run build && vitest run --config vitest.integration.config.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"check": "npm run lint && npm run typecheck && npm run test",
Expand Down
111 changes: 110 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { createCli, main } from './index.js';
import { createCli, main, buildLegacyArgv } from './index.js';
import { VERSION } from './version.js';

describe('sphere-cli scaffold', () => {
Expand Down Expand Up @@ -71,3 +71,112 @@ describe('sphere-cli scaffold', () => {
expect(help).toContain('legacy bridge');
});
});

describe('buildLegacyArgv dispatcher', () => {
// Wallet namespace: most subcommands pass through unchanged, but
// `init` and `status` remap to legacy top-level commands.
describe('wallet', () => {
it('`wallet init` remaps to legacy top-level `init`', () => {
expect(buildLegacyArgv('wallet', ['init', '--network', 'testnet']))
.toEqual(['init', '--network', 'testnet']);
});
it('`wallet status` remaps to legacy top-level `status`', () => {
expect(buildLegacyArgv('wallet', ['status'])).toEqual(['status']);
});
it('`wallet current` falls through to legacy `wallet current`', () => {
expect(buildLegacyArgv('wallet', ['current'])).toEqual(['wallet', 'current']);
});
it('`wallet list` falls through to legacy `wallet list`', () => {
expect(buildLegacyArgv('wallet', ['list'])).toEqual(['wallet', 'list']);
});
it('`wallet create foo --network testnet` preserves flags after subcommand', () => {
expect(buildLegacyArgv('wallet', ['create', 'foo', '--network', 'testnet']))
.toEqual(['wallet', 'create', 'foo', '--network', 'testnet']);
});
it('bare `wallet` with no subcommand produces `[wallet]`', () => {
expect(buildLegacyArgv('wallet', [])).toEqual(['wallet']);
});
});

// Simple 1:1 namespaces preserve tail verbatim.
describe('1:1 namespaces', () => {
it.each(['balance', 'daemon', 'config', 'completions'])(
'`%s x y` → [`%s`, x, y]',
(ns) => {
expect(buildLegacyArgv(ns, ['x', 'y'])).toEqual([ns, 'x', 'y']);
},
);
});

describe('faucet → topup', () => {
it('remaps namespace name', () => {
expect(buildLegacyArgv('faucet', ['--amount', '1'])).toEqual(['topup', '--amount', '1']);
});
});

describe('nametag', () => {
it('bare `nametag foo` → `[nametag, foo]`', () => {
expect(buildLegacyArgv('nametag', ['alice'])).toEqual(['nametag', 'alice']);
});
it('`nametag register alice` → `[nametag, alice]`', () => {
expect(buildLegacyArgv('nametag', ['register', 'alice'])).toEqual(['nametag', 'alice']);
});
it('`nametag info alice` → `[nametag-info, alice]`', () => {
expect(buildLegacyArgv('nametag', ['info', 'alice'])).toEqual(['nametag-info', 'alice']);
});
it('`nametag my` → `[my-nametag]`', () => {
expect(buildLegacyArgv('nametag', ['my'])).toEqual(['my-nametag']);
});
it('`nametag sync` → `[nametag-sync]`', () => {
expect(buildLegacyArgv('nametag', ['sync'])).toEqual(['nametag-sync']);
});
});

describe('namespace-stripped (payments, crypto, util)', () => {
it('payments strips namespace', () => {
expect(buildLegacyArgv('payments', ['send', 'alice', '1'])).toEqual(['send', 'alice', '1']);
});
it('crypto strips namespace', () => {
expect(buildLegacyArgv('crypto', ['generate-key'])).toEqual(['generate-key']);
});
it('util strips namespace', () => {
expect(buildLegacyArgv('util', ['to-human', '100'])).toEqual(['to-human', '100']);
});
});

describe('dm', () => {
it('bare `dm @alice hi` → `[dm, @alice, hi]`', () => {
expect(buildLegacyArgv('dm', ['@alice', 'hi'])).toEqual(['dm', '@alice', 'hi']);
});
it('`dm send @alice hi` strips `send`', () => {
expect(buildLegacyArgv('dm', ['send', '@alice', 'hi'])).toEqual(['dm', '@alice', 'hi']);
});
it('`dm inbox` → `[dm-inbox]`', () => {
expect(buildLegacyArgv('dm', ['inbox'])).toEqual(['dm-inbox']);
});
it('`dm history @alice` → `[dm-history, @alice]`', () => {
expect(buildLegacyArgv('dm', ['history', '@alice'])).toEqual(['dm-history', '@alice']);
});
});

describe('prefixed namespaces (group, market, swap, invoice)', () => {
it('`swap propose` → `[swap-propose]`', () => {
expect(buildLegacyArgv('swap', ['propose'])).toEqual(['swap-propose']);
});
it('`market search foo` → `[market-search, foo]`', () => {
expect(buildLegacyArgv('market', ['search', 'foo'])).toEqual(['market-search', 'foo']);
});
it('`invoice pay INV-1` → `[invoice-pay, INV-1]`', () => {
expect(buildLegacyArgv('invoice', ['pay', 'INV-1'])).toEqual(['invoice-pay', 'INV-1']);
});
it('bare `swap` (no subcommand) → `[swap]`', () => {
expect(buildLegacyArgv('swap', [])).toEqual(['swap']);
});
});

describe('unknown namespace', () => {
it('passes through as-is (safety net for future namespaces)', () => {
expect(buildLegacyArgv('weirdname', ['a', 'b'])).toEqual(['weirdname', 'a', 'b']);
});
});
});
22 changes: 17 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,25 @@ const PHASE4_NAMESPACES: Array<[string, string]> = [
* - `dm`, `group`, `market`, `swap`, `invoice` prefix the subcommand with
* `<namespace>-` (e.g. `sphere swap propose` → `swap-propose`).
*/
function buildLegacyArgv(namespace: string): string[] {
// tail = everything after: node sphere <namespace>
const tail = process.argv.slice(3);

/**
* Translate a commander namespace + tail into the argv shape that the
* legacy sphere-sdk CLI switch/case dispatcher expects.
*
* Exported (with the `tail` parameter explicit) so that the dispatch table
* can be unit-tested without spawning processes or mocking `process.argv`.
* The live path passes `process.argv.slice(3)` from the commander action.
*/
export function buildLegacyArgv(namespace: string, tail: string[] = process.argv.slice(3)): string[] {
switch (namespace) {
// These namespaces directly match legacy top-level commands — keep namespace as command
case 'wallet': return ['wallet', ...tail];
// wallet: most subcommands (list, use, create, current, delete) are 'wallet <sub>';
// but 'wallet init' + 'wallet status' are legacy top-level commands, remapped here.
case 'wallet': {
const [sub, ...rest] = tail;
if (sub === 'init') return ['init', ...rest]; // legacy top-level `init`
if (sub === 'status') return ['status', ...rest]; // legacy top-level `status`
return ['wallet', ...tail];
}
case 'balance': return ['balance', ...tail];
case 'daemon': return ['daemon', ...tail];
case 'config': return ['config', ...tail];
Expand Down
113 changes: 113 additions & 0 deletions test/integration/00-preflight.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Preflight health check for the public testnet endpoints.
*
* Runs first (filename sorts before cli-*) and sets a module-level flag
* the other integration suites read via beforeAll. When any endpoint is
* down, subsequent tests are skipped with a clear reason rather than
* failing with opaque "sphere wallet init exited 1" messages.
*
* Each probe has a 5s budget — fast enough that a slow endpoint
* doesn't bloat CI time by more than ~15s worst case.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { connect as tlsConnect } from 'node:tls';
import { PUBLIC_TESTNET, integrationSkip } from './helpers.js';

const PROBE_TIMEOUT_MS = 5_000;

export interface PreflightResult {
aggregator: boolean;
ipfs: boolean;
nostr: boolean;
}

export const preflight: PreflightResult = {
aggregator: false,
ipfs: false,
nostr: false,
};

async function probeHttp(url: string): Promise<boolean> {
try {
const res = await fetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
// Any response that doesn't throw means the service responded.
// Aggregator JSON-RPC may return 405/404 for HEAD on /rpc — still "up".
return res.status < 600;
} catch {
return false;
}
}

/**
* Probe the Nostr relay's TCP+TLS layer. A full WebSocket handshake would
* need the `ws` package as a direct dependency; a TLS connect is enough
* to confirm the relay host is reachable and the certificate is valid.
* If the TLS layer accepts us, the WebSocket upgrade almost always works.
*/
async function probeTls(wssUrl: string): Promise<boolean> {
const u = new URL(wssUrl);
const port = u.port ? Number(u.port) : 443;
return new Promise((resolve) => {
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(hardTimeout);
try { socket.destroy(); } catch { /* ignore */ }
resolve(ok);
};
// Outer hard timeout — bounds DNS resolution and TCP SYN-drop paths
// that tls.connect's own `timeout` option does not cover (socket-level
// timeout only applies once the socket is writable, not during DNS
// lookup or initial connect on a restricted network).
const hardTimeout = setTimeout(() => done(false), PROBE_TIMEOUT_MS);
const socket = tlsConnect({
host: u.hostname,
port,
servername: u.hostname,
timeout: PROBE_TIMEOUT_MS,
});
socket.once('secureConnect', () => done(true));
socket.once('error', () => done(false));
socket.once('timeout', () => done(false));
});
}

describe.skipIf(integrationSkip)('integration preflight — public testnet reachability', () => {
beforeAll(async () => {
const [agg, ipfs, nostr] = await Promise.all([
probeHttp(PUBLIC_TESTNET.aggregator),
probeHttp(PUBLIC_TESTNET.ipfsGateway),
probeTls(PUBLIC_TESTNET.nostrRelay),
]);
preflight.aggregator = agg;
preflight.ipfs = ipfs;
preflight.nostr = nostr;
}, 30_000);

it('aggregator is reachable', () => {
expect(
preflight.aggregator,
`${PUBLIC_TESTNET.aggregator} did not respond within ${PROBE_TIMEOUT_MS}ms. ` +
`Downstream tests that require the aggregator will be skipped.`,
).toBe(true);
});

it('IPFS gateway is reachable', () => {
expect(
preflight.ipfs,
`${PUBLIC_TESTNET.ipfsGateway} did not respond within ${PROBE_TIMEOUT_MS}ms.`,
).toBe(true);
});

it('Nostr relay accepts WebSocket connection', () => {
expect(
preflight.nostr,
`${PUBLIC_TESTNET.nostrRelay} did not accept a WebSocket handshake within ${PROBE_TIMEOUT_MS}ms.`,
).toBe(true);
});
});
Loading
Loading