From 2ed933e72ddc68cd36f4827e41fc8fc542a9d3d2 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sat, 6 Jun 2026 13:33:51 +0200 Subject: [PATCH] fix(cli)(#40): bulk-return amount display + integration test scrub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes follow-ups #1 and #2 from issue #40. Item #1 — `sphere invoice return ` (bulk) shows actual refunded amount instead of `amount: "0"`. The bug: `getInvoiceStatus()` ran AFTER `returnAllInvoicePayments()`, by which point the SDK had drained `senderBalances[].netBalance` to 0 (refunds had been issued), so every row in the rendered output showed 0. Fix: snapshot status BEFORE the SDK call, mirror the SDK's iteration filters so `plan[i] ↔ results[i]`. Static-analysis pin in `legacy-cli-ux.test.ts` asserts the call ordering doesn't regress (first `await getInvoiceStatus(` must precede first `await returnAllInvoicePayments(` inside the invoice-return case). Item #2 — integration test backlog from PR #33 canonical-UX cutover. Down from 63 → 3 failures (remaining 3 are unrelated regressions, called out below). • `expectUsageHint(out, cmdName, positional?)` helper in `test/integration/helpers.ts`: tolerates the new `Usage: npm run cli -- ` prefix so the next help-format change is a one-line fix. All ~13 call sites updated. • JSON-shape stdout captures (`/"directAddress": "..."/` etc.) updated to canonical-UX labelled-block form (`/directAddress\s*:\s*.../`) across 9 test files. • `--asset "1000000 UCT"` quoted single-string form (rejected by the canonical asset-pair guard) replaced with two-positional form in cli-invoice/cli-swap. • `expect.toMatch(/Asset not found/)` → case-insensitive — the error string is lowercased by `failWithHelp` now. Same for `No invoice found matching prefix:`. • `crypto decrypt` now accepts BOTH bare base64 (the new canonical-UX `crypto encrypt` output) and JSON-quoted base64 (legacy `--json` callers). Restores the `encrypt foo pw | decrypt - pw` roundtrip. • Added `swap-ping` HELP_TEXT block and completion entry (the `failWithHelp('swap-ping', ...)` calls were already there; only the help registration was missing). Moved swap-ping into `SWAP_SUBCOMMANDS` and dropped the obsolete "no help available (HELP_TEXT gap pin)" test. Items #3 (`--asset-index` / `--target-index`) and #4 (NFT-only `invoice pay`) deferred per the issue's suggested order. Out of scope, remaining integration failures (3): • `cli-multiaddress`: `tokens/` subdir ENOENT after `payments switch 1` — likely Profile (OrbitDB) cutover relocating the per-address token store off `.sphere-cli/tokens/`. Separate bug; not Usage/UX cutover. • `cli-wallet-lifecycle`: `sphere clear --yes` succeeds, but `sphere status` afterwards auto-generates a new wallet and reports it instead of "No wallet found". Real CLI/SDK regression; needs a separate ticket. • `cli-wallet-lifecycle`: `init --nametag` flake — testnet nametag mint sometimes doesn't complete inside the timeout. Locally green when the mint succeeds; the regex now correctly matches the canonical UX `nametag : @` form. Unit tests (127) all green. Type-check + lint clean. --- src/legacy/legacy-cli-ux.test.ts | 49 ++++++++++ src/legacy/legacy-cli.ts | 90 +++++++++++++------ .../cli-assets.integration.test.ts | 9 +- .../cli-crypto.integration.test.ts | 42 ++++----- test/integration/cli-dm.integration.test.ts | 6 +- .../integration/cli-group.integration.test.ts | 7 +- .../cli-invoice.integration.test.ts | 41 +++++---- .../cli-market.integration.test.ts | 4 +- .../cli-multiaddress.integration.test.ts | 8 +- .../cli-nametag.integration.test.ts | 16 ++-- test/integration/cli-send.integration.test.ts | 4 +- .../cli-swap-e2e.integration.test.ts | 2 +- test/integration/cli-swap.integration.test.ts | 40 ++++----- .../cli-wallet-lifecycle.integration.test.ts | 27 +++--- .../cli-wallet-migrate.integration.test.ts | 2 +- .../cli-wallet-profile.integration.test.ts | 22 ++--- test/integration/helpers.ts | 41 +++++++++ 17 files changed, 271 insertions(+), 139 deletions(-) diff --git a/src/legacy/legacy-cli-ux.test.ts b/src/legacy/legacy-cli-ux.test.ts index f0dfe38..6678330 100644 --- a/src/legacy/legacy-cli-ux.test.ts +++ b/src/legacy/legacy-cli-ux.test.ts @@ -127,3 +127,52 @@ describe('issue #32 — UX consistency', () => { expect(SOURCE).toMatch(/args\.includes\('--help'\)\s*\|\|\s*args\.includes\('-h'\)/); }); }); + +describe('issue #40 item #1 — bulk-return amount display', () => { + // Static-analysis pin for the bulk-refund render bug: + // + // `sphere invoice return ` (no flags / --recipient form) used to + // call `getInvoiceStatus()` AFTER `returnAllInvoicePayments()`. By + // that point the SDK had drained `senderBalances[].netBalance` to 0 + // (refunds had been issued), so every refund row in the rendered + // output showed `amount: "0"`. Fix: snapshot status BEFORE the SDK + // call so we render the actual refunded amounts. + // + // Pin: inside the `case 'invoice-return':` block, the first + // `getInvoiceStatus(` call must appear BEFORE the first + // `returnAllInvoicePayments(` call. Source ordering reflects + // execution ordering here — both are top-level `await`s, no + // conditional skips between them. + + it('getInvoiceStatus is called BEFORE returnAllInvoicePayments in invoice-return', () => { + const caseStart = SOURCE.indexOf("case 'invoice-return':"); + expect(caseStart, "case 'invoice-return': not found").toBeGreaterThan(-1); + + // Locate end of case — next top-level `case '...'` or `default:` token. + const after = SOURCE.slice(caseStart + 1); + const nextCaseRel = after.search(/\n {6}case '[^']+':|\n {6}default:/); + const caseEnd = nextCaseRel >= 0 ? caseStart + 1 + nextCaseRel : SOURCE.length; + const block = SOURCE.slice(caseStart, caseEnd); + + // Match the actual `await sphere.accounting!.(` call sites, + // not bare `(` (would also match comment references like + // "iterates `getInvoiceStatus().senderBalances` internally"). + const statusCallRe = /await\s+sphere\.accounting!?\.getInvoiceStatus\s*\(/; + const returnAllRe = /await\s+sphere\.accounting!?\.returnAllInvoicePayments\s*\(/; + const statusMatch = statusCallRe.exec(block); + const returnAllMatch = returnAllRe.exec(block); + + expect( + statusMatch, + 'getInvoiceStatus() call missing from invoice-return — bulk-refund render needs the pre-refund snapshot.', + ).not.toBeNull(); + expect( + returnAllMatch, + 'returnAllInvoicePayments() call missing from invoice-return — Form B/C delegates to the SDK.', + ).not.toBeNull(); + expect( + statusMatch!.index, + 'getInvoiceStatus() must be called BEFORE returnAllInvoicePayments() so renderer shows pre-refund amounts (issue #40 item #1).', + ).toBeLessThan(returnAllMatch!.index); + }); +}); diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 3ff763e..4d1c1c2 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -1709,6 +1709,18 @@ const COMMAND_HELP: Record = { 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', ], }, + 'swap-ping': { + usage: 'swap-ping <@nametag_or_address>', + description: 'Ping an escrow service to verify it is online and reachable via the configured transport.', + examples: [ + 'npm run cli -- swap-ping @escrow', + 'npm run cli -- swap-ping DIRECT://0000abcd...', + ], + notes: [ + 'Accepts an @nametag, DIRECT://… address, or any other identifier swap-propose accepts.', + 'On success, prints the escrow\'s pong response (transport pubkey, capabilities).', + ], + }, 'swap-reject': { usage: 'swap-reject [reason]', @@ -3533,9 +3545,21 @@ async function main(): Promise { case 'decrypt': { const [, encrypted, password] = args; if (!encrypted || !password) { - failWithHelp('decrypt', 'missing required arguments'); + failWithHelp('decrypt', 'missing required arguments'); + } + // Accept both shapes: bare base64 (what `crypto encrypt` now + // emits under canonical UX) and JSON-quoted base64 (legacy + // wrapper, kept for backward compat with scripts that pipe + // `encrypt --json` output). Trying JSON.parse first preserves + // the legacy roundtrip; falling back to the raw string makes + // the natural `crypto encrypt | xargs crypto decrypt` pipeline + // work without manual quoting. + let encryptedData: string; + if (encrypted.startsWith('"') && encrypted.endsWith('"')) { + encryptedData = JSON.parse(encrypted) as string; + } else { + encryptedData = encrypted; } - const encryptedData = JSON.parse(encrypted); const result = decrypt(encryptedData, password); console.log(result); break; @@ -4961,6 +4985,30 @@ async function main(): Promise { if (explicitRecipient) { sdkOptions.recipient = await resolveRecipientToDirect(sphere, explicitRecipient, 'invoice-return'); } + + // Snapshot per-sender balances BEFORE the SDK call. The SDK drains + // `senderBalances[].netBalance` as it refunds, so re-reading status + // afterwards produces `amount: 0` rows (issue #40 item #1). Mirror + // the SDK's iteration order + filters so plan[i] ↔ results[i]. + const statusBefore = await sphere.accounting!.getInvoiceStatus(invoiceId); + const plan: Array<{ recipient: string; coinId: string; netBalance: string }> = []; + for (const target of statusBefore.targets) { + for (const ca of target.coinAssets) { + const [coinId] = ca.coin; + for (const sb of ca.senderBalances) { + let bal: bigint; + try { + bal = BigInt(sb.netBalance); + } catch { + continue; + } + if (bal <= 0n) continue; + if (sdkOptions.recipient !== undefined && sb.senderAddress !== sdkOptions.recipient) continue; + plan.push({ recipient: sb.senderAddress, coinId, netBalance: sb.netBalance }); + } + } + } + const results = await sphere.accounting!.returnAllInvoicePayments(invoiceId, sdkOptions); if (results.length === 0) { const reason = explicitRecipient @@ -4968,31 +5016,20 @@ async function main(): Promise { : `no refundable balance found on invoice ${invoiceId.slice(0, 16)}… — nothing to return`; failWithHelp('invoice-return', reason); } - // The SDK returns TransferResult per row but doesn't echo back the - // (recipient, amount, coin) tuple — re-attach those from status so - // the human renderer can show what got refunded where. - const status = await sphere.accounting!.getInvoiceStatus(invoiceId); + const refundRows: Array> = []; - let cursor = 0; - for (const target of status.targets) { - for (const ca of target.coinAssets) { - const [coinId] = ca.coin; - const { decimals } = resolveCoin(coinId); - for (const sb of ca.senderBalances) { - // Skip rows that returnAllInvoicePayments wouldn't have - // refunded (zero balance, or recipient filter). - if (sdkOptions.recipient !== undefined && sb.senderAddress !== sdkOptions.recipient) continue; - if (cursor >= results.length) break; - const result = results[cursor++]; - refundRows.push({ - id: result.id, - status: result.status, - recipient: sb.senderAddress, - amount: toHumanReadable(sb.netBalance, decimals), - coin: coinId, - }); - } - } + const rowCount = Math.min(plan.length, results.length); + for (let i = 0; i < rowCount; i++) { + const row = plan[i]; + const result = results[i]; + const { decimals } = resolveCoin(row.coinId); + refundRows.push({ + id: result.id, + status: result.status, + recipient: row.recipient, + amount: toHumanReadable(row.netBalance, decimals), + coin: row.coinId, + }); } formatOutput( { refunds: refundRows } as Record, @@ -5787,6 +5824,7 @@ function getCompletionCommands(): CompletionCommand[] { { name: 'swap-accept', description: 'Accept a swap deal', flags: ['--deposit', '--no-wait'] }, { name: 'swap-status', description: 'Show swap status', flags: ['--query-escrow'] }, { name: 'swap-deposit', description: 'Deposit into a swap' }, + { name: 'swap-ping', description: 'Ping an escrow service' }, { name: 'swap-reject', description: 'Reject a swap proposal' }, { name: 'swap-cancel', description: 'Cancel a swap' }, { name: 'market-post', description: 'Post a market intent', flags: ['--type', '--category', '--price', '--currency', '--location', '--contact', '--expires'] }, diff --git a/test/integration/cli-assets.integration.test.ts b/test/integration/cli-assets.integration.test.ts index cf50ccc..5102d36 100644 --- a/test/integration/cli-assets.integration.test.ts +++ b/test/integration/cli-assets.integration.test.ts @@ -45,6 +45,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, integrationSkip, type SphereEnv, @@ -92,8 +93,7 @@ describe('sphere-cli — asset-info arg validation (offline)', () => { // "did I type the right command" probe offline-fast for users. const r = runSphere(env, ['payments', 'asset-info'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*asset-info\s*/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'asset-info', ''); }); }); @@ -175,7 +175,10 @@ describe.skipIf(integrationSkip)( const r = runSphere(env, ['payments', 'asset-info', 'NOT_A_REAL_TOKEN_ZZZ'], { timeoutMs: 120_000 }); expect(r.status).not.toBe(0); const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Asset not found/); + // Case-insensitive because failWithHelp lowercases its prose + // ("asset not found: ...") — the asserted invariant is the + // negative-lookup verdict reaching the user, not the casing. + expect(out).toMatch(/asset not found/i); }, 180_000); }, ); diff --git a/test/integration/cli-crypto.integration.test.ts b/test/integration/cli-crypto.integration.test.ts index f95a470..0b9a757 100644 --- a/test/integration/cli-crypto.integration.test.ts +++ b/test/integration/cli-crypto.integration.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, type SphereEnv, } from './helpers.js'; @@ -113,8 +114,7 @@ describe('sphere-cli — crypto/util arg validation (offline)', () => { ])('`sphere crypto %s` with no args prints usage and exits non-zero', (sub, legacyName) => { const r = runSphere(env, ['crypto', sub], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i')); + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it.each([ @@ -124,8 +124,7 @@ describe('sphere-cli — crypto/util arg validation (offline)', () => { ])('`sphere util %s` with no args prints usage and exits non-zero', (sub, legacyName) => { const r = runSphere(env, ['util', sub], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i')); + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it('`sphere crypto encrypt foo` (missing password) prints usage and exits non-zero', () => { @@ -133,8 +132,7 @@ describe('sphere-cli — crypto/util arg validation (offline)', () => { // also hits the pre-getSphere() guard. const r = runSphere(env, ['crypto', 'encrypt', 'foo'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*encrypt|usage:\s*encrypt/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'encrypt'); }); }); @@ -187,17 +185,21 @@ describe('sphere-cli — crypto behaviour (offline)', () => { it('`sphere crypto validate-key ` accepts a valid private key', () => { const r = runSphere(env, ['crypto', 'validate-key', TEST_PRIVKEY]); expect(r.status).toBe(0); - // Output is JSON: {"valid":true,"length":64} - expect(r.stdout).toMatch(/"valid":\s*true/); - expect(r.stdout).toMatch(/"length":\s*64/); + // Canonical-UX output is human-friendly labelled blocks + // ` valid : true` + // ` length : 64` + // Pad-aware regex (zero-or-more whitespace around `:`) so future + // alignment tweaks don't rip these out. + expect(r.stdout).toMatch(/valid\s*:\s*true/); + expect(r.stdout).toMatch(/length\s*:\s*64/); }); it('`sphere crypto validate-key not-hex` rejects an invalid key', () => { // Per the help text: "Exits with code 0 if valid, 1 if invalid." - // So we expect non-zero exit AND a "valid":false JSON. + // So we expect non-zero exit AND `valid : false` in the output. const r = runSphere(env, ['crypto', 'validate-key', 'not-hex']); expect(r.status).not.toBe(0); - expect(r.stdout).toMatch(/"valid":\s*false/); + expect(r.stdout).toMatch(/valid\s*:\s*false/); }); it('`sphere crypto hex-to-wif` produces a stable WIF for the test private key', () => { @@ -274,17 +276,17 @@ describe('sphere-cli — util behaviour (offline)', () => { }); it('`sphere crypto encrypt` / `decrypt` roundtrips a string with a password', () => { - // Pin the AES envelope: encrypt produces a JSON-quoted base64 blob + // Pin the AES envelope: encrypt produces a base64 blob // ("U2FsdGVkX1+..."), decrypt restores the original plaintext. // Failure mode pinned by a regression: a change in the cipher, // salt format, or PBKDF2 iteration count would either fail the // decrypt or yield a different plaintext. // - // IMPORTANT: decrypt's first positional MUST be the JSON-quoted - // form ("U2Fsd..."), not the bare base64. The handler runs - // JSON.parse on argv[1] to extract the string. Stripping the - // surrounding quotes here would make decrypt fail with - // "Unexpected token 'U', ... is not valid JSON". + // Canonical UX: `crypto encrypt` emits bare base64 (no JSON + // wrapping) so `crypto encrypt foo pw | crypto decrypt - pw` + // pipelines naturally. `crypto decrypt` accepts both the bare form + // AND the JSON-quoted legacy form, so scripts piping `--json` + // output continue to work. const plaintext = 'integration-test-secret'; const password = 'p4ssw0rd-test'; const enc = runSphere(env, ['crypto', 'encrypt', plaintext, password]); @@ -293,9 +295,9 @@ describe('sphere-cli — util behaviour (offline)', () => { expect(ciphertext.length).toBeGreaterThan(2); // OpenSSL-compatible AES envelope starts with the magic "Salted__" // header, base64-encoded as "U2FsdGVkX1" (zero-pad). Pin the prefix - // (inside the quote) so a switch to a non-OpenSSL-compatible scheme - // breaks compat-hungry downstream tooling. - expect(ciphertext).toMatch(/^"U2FsdGVkX1/); + // so a switch to a non-OpenSSL-compatible scheme breaks + // compat-hungry downstream tooling. + expect(ciphertext).toMatch(/^U2FsdGVkX1/); const dec = runSphere(env, ['crypto', 'decrypt', ciphertext, password]); expect(dec.status).toBe(0); diff --git a/test/integration/cli-dm.integration.test.ts b/test/integration/cli-dm.integration.test.ts index d24513c..ffe23ea 100644 --- a/test/integration/cli-dm.integration.test.ts +++ b/test/integration/cli-dm.integration.test.ts @@ -48,9 +48,9 @@ describe.skipIf(integrationSkip)('sphere-cli integration — DM round-trip (real throw new Error('wallet init failed; cannot proceed with DM tests'); } - // Identity JSON is emitted as pretty-printed JSON inside the init output. - // Extract directAddress with a lenient regex (order-independent of other fields). - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + // Canonical UX init emits ` directAddress : DIRECT://...` as a + // labelled line inside the identity block. + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); directAddress = match[1]!; }, 180_000); diff --git a/test/integration/cli-group.integration.test.ts b/test/integration/cli-group.integration.test.ts index 7e5d0f4..8fde26d 100644 --- a/test/integration/cli-group.integration.test.ts +++ b/test/integration/cli-group.integration.test.ts @@ -39,6 +39,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, type SphereEnv, } from './helpers.js'; @@ -108,8 +109,7 @@ describe('sphere-cli — group arg validation (offline)', () => { ])('`sphere group %s` with no args prints usage and exits non-zero', (sub, legacyName) => { const r = runSphere(env, ['group', sub], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i')); + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it('`sphere group send ` (missing message) prints usage and exits non-zero', () => { @@ -117,7 +117,6 @@ describe('sphere-cli — group arg validation (offline)', () => { // hits the pre-getSphere() guard. const r = runSphere(env, ['group', 'send', '00deadbeef'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*group-send|usage:\s*group-send/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'group-send'); }); }); diff --git a/test/integration/cli-invoice.integration.test.ts b/test/integration/cli-invoice.integration.test.ts index fc3c22d..fde0b10 100644 --- a/test/integration/cli-invoice.integration.test.ts +++ b/test/integration/cli-invoice.integration.test.ts @@ -46,6 +46,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, integrationSkip, type SphereEnv, @@ -138,21 +139,18 @@ describe('sphere-cli — invoice arg validation (offline)', () => { // $id is empty. expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - // The legacy CLI prints "Usage: ..." to stderr. If a - // refactor moves the arg check below the wallet load, this regex - // flips red (the user would instead see "No wallet exists ..."). - expect(out, `${sub} should show usage hint`).toMatch( - new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'), - ); + // The legacy CLI prints "Usage: npm run cli -- ..." to + // stderr. If a refactor moves the arg check below the wallet load, + // this helper flips red (the user would instead see "No wallet + // exists ..."). + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it('`sphere invoice parse-memo` with no memo prints usage and exits non-zero', () => { // parse-memo's case also validates `args[1]` before wallet load. const r = runSphere(env, ['invoice', 'parse-memo'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*invoice-parse-memo|usage:\s*invoice-parse-memo/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'invoice-parse-memo'); }); }); @@ -170,7 +168,8 @@ describe.skipIf(integrationSkip)( console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); throw new Error('wallet init failed; cannot proceed with invoice lifecycle tests'); } - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + // Canonical UX init emits ` directAddress : DIRECT://...`. + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); directAddress = match[1]!; }, 180_000); @@ -193,7 +192,10 @@ describe.skipIf(integrationSkip)( const r = runSphere( env, - ['invoice', 'create', '--target', directAddress!, '--asset', '1000000 UCT', '--memo', 'integration-test'], + // Canonical UX expects `--asset ` as two positional + // tokens (PR #33). The quoted single-string form would trip the + // asset-pair guard before invoice-create can reach the SDK. + ['invoice', 'create', '--target', directAddress!, '--asset', '1000000', 'UCT', '--memo', 'integration-test'], { timeoutMs: 180_000 }, ); @@ -201,11 +203,11 @@ describe.skipIf(integrationSkip)( console.error('invoice create failed', { stdout: r.stdout, stderr: r.stderr }); } expect(r.status).toBe(0); - // Legacy CLI prints "Invoice created:" then the JSON.stringify of - // the result, which includes an `invoiceId` field. Extract it for - // the downstream status / close pins. + // Canonical UX prints "Invoice created:" then a labelled block: + // ` invoiceId : ` + // Extract the id for downstream status / close pins. expect(r.stdout).toMatch(/Invoice created:/); - const idMatch = r.stdout.match(/"invoiceId":\s*"([0-9a-fA-F]+)"/); + const idMatch = r.stdout.match(/invoiceId\s*:\s*([0-9a-fA-F]+)/); expect(idMatch, `invoiceId not found in output:\n${r.stdout}`).toBeTruthy(); invoiceId = idMatch![1]!; // Invoice token id is hex, ≥ 64 chars (state-transition-sdk token @@ -237,7 +239,8 @@ describe.skipIf(integrationSkip)( expect(r.status).toBe(0); expect(r.stdout).toMatch(/Invoice Status:/); // OPEN is the entry state — invoice was just minted, no payments yet. - expect(r.stdout).toMatch(/"state":\s*"OPEN"/); + // Canonical UX format: ` state : OPEN`. + expect(r.stdout).toMatch(/state\s*:\s*OPEN/); }, 180_000); // Regression pin for sphere-cli #21: `sphere invoice status ` for @@ -271,7 +274,9 @@ describe.skipIf(integrationSkip)( const r = runSphere(env, ['invoice', 'status', bogus], { timeoutMs: 120_000 }); expect(r.status).toBe(1); - expect(r.stderr).toMatch(/No invoice found matching prefix:/); + // Case-insensitive — failWithHelp lowercases its prose: + // `Error: no invoice found matching prefix: ...` + expect(r.stderr).toMatch(/no invoice found matching prefix:/i); // The crash signature from #21. If this match flips green, the // process.exit wrapper has regressed back to its pre-#21 form. const combined = `${r.stdout}\n${r.stderr}`; @@ -310,7 +315,7 @@ describe.skipIf(integrationSkip)( // Verify the state transition stuck — `invoice status` now reports CLOSED. const status = runSphere(env, ['invoice', 'status', prefix], { timeoutMs: 120_000 }); expect(status.status).toBe(0); - expect(status.stdout).toMatch(/"state":\s*"CLOSED"/); + expect(status.stdout).toMatch(/state\s*:\s*CLOSED/); }, 360_000); }, ); diff --git a/test/integration/cli-market.integration.test.ts b/test/integration/cli-market.integration.test.ts index 2ae54c6..adfd3c5 100644 --- a/test/integration/cli-market.integration.test.ts +++ b/test/integration/cli-market.integration.test.ts @@ -39,6 +39,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, type SphereEnv, } from './helpers.js'; @@ -101,8 +102,7 @@ describe('sphere-cli — market arg validation (offline)', () => { ])('`sphere market %s` with no args prints usage and exits non-zero', (sub, legacyName) => { const r = runSphere(env, ['market', sub], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i')); + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it('`sphere market post ""` (missing --type) rejects with required-flag error', () => { diff --git a/test/integration/cli-multiaddress.integration.test.ts b/test/integration/cli-multiaddress.integration.test.ts index 430d911..3affdd2 100644 --- a/test/integration/cli-multiaddress.integration.test.ts +++ b/test/integration/cli-multiaddress.integration.test.ts @@ -63,6 +63,7 @@ import { join } from 'node:path'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, integrationSkip, type SphereEnv, @@ -119,10 +120,7 @@ describe('sphere-cli — multiaddress arg validation (offline)', () => { ])('`sphere %s` prints usage and exits non-zero', (_label, argv, legacyName) => { const r = runSphere(env, argv, { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out, `${legacyName} should show usage hint`).toMatch( - new RegExp(`Usage:\\s*${legacyName}\\s*`, 'i'), - ); + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName, ''); }); it('`sphere payments switch abc` rejects non-numeric index with "Invalid index"', () => { @@ -159,7 +157,7 @@ describe.skipIf(integrationSkip)( console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); throw new Error('wallet init failed; cannot proceed with multiaddress lifecycle'); } - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); directAddr0 = match[1]!; }, 180_000); diff --git a/test/integration/cli-nametag.integration.test.ts b/test/integration/cli-nametag.integration.test.ts index a8418f4..123d2f4 100644 --- a/test/integration/cli-nametag.integration.test.ts +++ b/test/integration/cli-nametag.integration.test.ts @@ -46,6 +46,7 @@ import { randomBytes } from 'node:crypto'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, integrationSkip, type SphereEnv, @@ -122,14 +123,11 @@ describe('sphere-cli — nametag arg validation (offline)', () => { // empty. expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - // The legacy CLI prints "Usage: " to stderr. - // If a refactor moves the arg check below getSphere(), this regex - // flips red (the user would instead see "No wallet exists ..." or - // similar wallet-load output). - expect(out, `${legacyName} should show usage hint`).toMatch( - new RegExp(`Usage:\\s*${legacyName}\\s*`, 'i'), - ); + // The legacy CLI prints "Usage: npm run cli -- + // " to stderr. If a refactor moves the arg check below + // getSphere(), this helper flips red (the user would instead see + // "No wallet exists ..." or similar wallet-load output). + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName, ''); }); }); @@ -154,7 +152,7 @@ describe.skipIf(integrationSkip)( } // Sanity-check the wallet has a directAddress — confirms init // completed and we have an identity to bind the nametag to. - expect(init.stdout).toMatch(/"directAddress":\s*"DIRECT:\/\/[0-9a-fA-F]+"/); + expect(init.stdout).toMatch(/directAddress\s*:\s*DIRECT:\/\/[0-9a-fA-F]+/); }, 180_000); afterAll(() => { if (env) destroySphereEnv(env); }); diff --git a/test/integration/cli-send.integration.test.ts b/test/integration/cli-send.integration.test.ts index 6d613ef..779b849 100644 --- a/test/integration/cli-send.integration.test.ts +++ b/test/integration/cli-send.integration.test.ts @@ -114,7 +114,7 @@ describe.skipIf(integrationSkip)('sphere-cli integration — send end-to-end (re } // Reuse the same extraction shape as cli-dm.integration.test.ts. - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); directAddress = match[1]!; }, 180_000); @@ -224,7 +224,7 @@ describe.skipIf(integrationSkip || !FUNDED_MNEMONIC)( throw new Error('receiver wallet init failed'); } - const match = initReceiver.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const match = initReceiver.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!match) throw new Error(`receiver directAddress not found in init output`); receiverDirectAddress = match[1]!; }, 360_000); diff --git a/test/integration/cli-swap-e2e.integration.test.ts b/test/integration/cli-swap-e2e.integration.test.ts index bbadc2d..52f224f 100644 --- a/test/integration/cli-swap-e2e.integration.test.ts +++ b/test/integration/cli-swap-e2e.integration.test.ts @@ -104,7 +104,7 @@ async function provisionWallet(opts: { `stderr: ${init.stderr.slice(0, 800)}`, ); } - const addrMatch = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const addrMatch = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); if (!addrMatch) { throw new Error(`[${opts.label}] directAddress not found in init output`); } diff --git a/test/integration/cli-swap.integration.test.ts b/test/integration/cli-swap.integration.test.ts index 05783b3..bf170ca 100644 --- a/test/integration/cli-swap.integration.test.ts +++ b/test/integration/cli-swap.integration.test.ts @@ -39,6 +39,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, type SphereEnv, } from './helpers.js'; @@ -69,6 +70,7 @@ const SWAP_SUBCOMMANDS: ReadonlyArray<{ { legacy: 'swap-accept', mustMatch: [//, /--deposit/, /--no-wait/] }, { legacy: 'swap-status', mustMatch: [//, /--query-escrow/] }, { legacy: 'swap-deposit', mustMatch: [//] }, + { legacy: 'swap-ping', mustMatch: [/<@nametag_or_address>/] }, { legacy: 'swap-reject', mustMatch: [//] }, { legacy: 'swap-cancel', mustMatch: [//] }, ]; @@ -100,17 +102,9 @@ describe('sphere-cli — swap command shape (offline)', () => { }); } - it('`sphere payments help swap-ping` reports "no help available" (HELP_TEXT gap pin)', () => { - // swap-ping has no entry in the legacy HELP_TEXT map. This test PINS - // the current behavior so a future contributor who adds help to it - // sees the test flip and is prompted to add `swap-ping` to the - // SWAP_SUBCOMMANDS table above instead of silently breaking the - // "all swap commands have help" promise. - const r = runSphere(env, ['payments', 'help', 'swap-ping'], { timeoutMs: 15_000 }); - expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/No help available.*swap-ping/i); - }); + // (PR for issue #40 item #2 filled the swap-ping HELP_TEXT gap and + // moved swap-ping into SWAP_SUBCOMMANDS above. The old "no help + // available" gap-pin test was removed since help is now present.) }); describe('sphere-cli — swap arg validation (offline)', () => { @@ -146,13 +140,11 @@ describe('sphere-cli — swap arg validation (offline)', () => { // $id is empty. expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - // The legacy CLI prints "Usage: ..." to stderr. If a - // refactor moves the arg check below the wallet load, this regex - // flips red (the user would instead see "No wallet exists ..."). - expect(out, `${sub} should show usage hint`).toMatch( - new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'), - ); + // The legacy CLI prints "Usage: npm run cli -- ..." to + // stderr. If a refactor moves the arg check below the wallet load, + // this helper flips red (the user would instead see "No wallet + // exists ..."). + expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName); }); it('`sphere swap propose` with no flags prints usage and exits non-zero', () => { @@ -162,8 +154,7 @@ describe('sphere-cli — swap arg validation (offline)', () => { // every branch of the guard at once. const r = runSphere(env, ['swap', 'propose'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*swap-propose|usage:\s*swap-propose/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'swap-propose'); }); it('`sphere swap propose --to @bob --offer "10 UCT"` without --want fails arg-check', () => { @@ -174,8 +165,7 @@ describe('sphere-cli — swap arg validation (offline)', () => { timeoutMs: 15_000, }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Usage:\s*swap-propose|usage:\s*swap-propose/i); + expectUsageHint(`${r.stdout}\n${r.stderr}`, 'swap-propose'); }); it('`sphere swap propose --to @bob --offer X --want Y --timeout 10` rejects out-of-range timeout', () => { @@ -184,9 +174,13 @@ describe('sphere-cli — swap arg validation (offline)', () => { // Use `10` (below 60) to hit the validation. Use parseable assets so // the earlier coin resolution doesn't trip first — though even with // bogus assets, the timeout check fires first because it's syntactic. + // Canonical UX expects `--offer ` as two positional + // tokens (PR #33). Passing them as a single quoted string would now + // trip the asset-pair guard before the --timeout validation, so we + // split each pair into separate argv entries. const r = runSphere( env, - ['swap', 'propose', '--to', '@bob', '--offer', '10 UCT', '--want', '20 USDU', + ['swap', 'propose', '--to', '@bob', '--offer', '10', 'UCT', '--want', '20', 'USDU', '--timeout', '10'], { timeoutMs: 15_000 }, ); diff --git a/test/integration/cli-wallet-lifecycle.integration.test.ts b/test/integration/cli-wallet-lifecycle.integration.test.ts index 7fc9af2..f2ad519 100644 --- a/test/integration/cli-wallet-lifecycle.integration.test.ts +++ b/test/integration/cli-wallet-lifecycle.integration.test.ts @@ -100,14 +100,14 @@ describe('sphere-cli — config get/set (local, no network)', () => { beforeAll(() => { env = createSphereEnv('lifecycle-config'); }); afterAll(() => { destroySphereEnv(env); }); - it('`sphere config` (no args) prints the current configuration as JSON', () => { + it('`sphere config` (no args) prints the current configuration', () => { const r = runSphere(env, ['config'], { timeoutMs: 15_000 }); expect(r.status).toBe(0); - // Output header + valid JSON body. The createSphereEnv helper - // seeded testnet into config.json, so the JSON should reflect - // that. + // Canonical UX output is labelled blocks + // ` network : testnet` + // Pad-aware regex tolerates future alignment tweaks. expect(r.stdout).toMatch(/Current Configuration:/); - expect(r.stdout).toMatch(/"network":\s*"testnet"/); + expect(r.stdout).toMatch(/network\s*:\s*testnet/); }); it('`sphere config set network dev` mutates the network setting on disk', () => { @@ -131,7 +131,9 @@ describe('sphere-cli — config get/set (local, no network)', () => { const r = runSphere(env, ['config', 'set', 'bogus-key', 'value'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Unknown config key:\s*bogus-key/); + // Canonical UX wraps the rejected key in quotes: + // `Unknown config key: "bogus-key" (valid keys: ...)` + expect(out).toMatch(/Unknown config key:\s*"?bogus-key"?/); // The hint enumerates valid keys (~line 1743). If a future // refactor adds a new valid key without updating the error // hint, that's a doc bug — pin the three current keys. @@ -178,7 +180,7 @@ describe.skipIf(integrationSkip)( expect(mnemonicMatch, `mnemonic not in stdout:\n${init.stdout}`).toBeTruthy(); capturedMnemonic = mnemonicMatch![1]!; - const addrMatch = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const addrMatch = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); expect(addrMatch, `directAddress not in stdout:\n${init.stdout}`).toBeTruthy(); originalDirectAddress = addrMatch![1]!; }, 180_000); @@ -248,7 +250,7 @@ describe.skipIf(integrationSkip)( } expect(reinit.status).toBe(0); - const addrMatch = reinit.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const addrMatch = reinit.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); expect(addrMatch, `directAddress not in reinit:\n${reinit.stdout}`).toBeTruthy(); // THE DETERMINISM PIN: same mnemonic → same directAddress. // A regression in BIP-39 → seed → HD-path → secp256k1 → bech32 @@ -296,10 +298,11 @@ describe.skipIf(integrationSkip)( console.error('init --nametag failed', { stdout: r.stdout, stderr: r.stderr }); } expect(r.status).toBe(0); - // The identity block (~line 1654) must include the registered - // nametag in the JSON output. If init succeeds but nametag mint - // fails silently, the field would be null/absent here. - expect(r.stdout).toMatch(new RegExp(`"nametag":\\s*"${nametag}"`)); + // Canonical UX identity block (~line 1654) emits a labelled line + // ` nametag : @` + // (renderIdentity prepends `@` to the nametag value; absent + // nametags show `(none)`). + expect(r.stdout).toMatch(new RegExp(`nametag\\s*:\\s*@${nametag}`)); // The wallet directory now exists on disk. expect(existsSync(join(env.home, '.sphere-cli', 'wallet.json'))).toBe(true); }, 300_000); diff --git a/test/integration/cli-wallet-migrate.integration.test.ts b/test/integration/cli-wallet-migrate.integration.test.ts index d4cf00b..a63b792 100644 --- a/test/integration/cli-wallet-migrate.integration.test.ts +++ b/test/integration/cli-wallet-migrate.integration.test.ts @@ -139,7 +139,7 @@ describe.skipIf(integrationSkip)( // legacy step — we'll assert the migrate-applied wallet // re-derives the same directAddress so identity continuity // across the migration is visible at the test layer. - const m = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const m = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); expect(m, `directAddress not in init output:\n${init.stdout}`).toBeTruthy(); directAddress = m![1]!; }, 180_000); diff --git a/test/integration/cli-wallet-profile.integration.test.ts b/test/integration/cli-wallet-profile.integration.test.ts index 45c3dbe..ed41fe5 100644 --- a/test/integration/cli-wallet-profile.integration.test.ts +++ b/test/integration/cli-wallet-profile.integration.test.ts @@ -62,6 +62,7 @@ import { join } from 'node:path'; import { createSphereEnv, destroySphereEnv, + expectUsageHint, runSphere, integrationSkip, type SphereEnv, @@ -120,13 +121,10 @@ describe('sphere-cli — wallet profile arg validation (offline)', () => { ])('`sphere %s` prints usage and exits non-zero', (_label, argv, hint) => { const r = runSphere(env, argv, { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); - const out = `${r.stdout}\n${r.stderr}`; - // Each handler prints "Usage: " to stderr (~lines - // 1815, 1845, 1935). Match the hint without binding to the - // example-suffix wording. - expect(out, `${hint} should show usage hint`).toMatch( - new RegExp(`Usage:\\s*${hint}\\s*`, 'i'), - ); + // Each handler prints "Usage: npm run cli -- " to + // stderr (~lines 1815, 1845, 1935). Match the hint without binding + // to the example-suffix wording. + expectUsageHint(`${r.stdout}\n${r.stderr}`, hint, ''); }); it('`sphere wallet create !invalid` rejects names with disallowed characters', () => { @@ -148,7 +146,9 @@ describe('sphere-cli — wallet profile arg validation (offline)', () => { const r = runSphere(env, ['wallet', 'bogus-sub'], { timeoutMs: 15_000 }); expect(r.status).not.toBe(0); const out = `${r.stdout}\n${r.stderr}`; - expect(out).toMatch(/Unknown wallet subcommand:\s*bogus-sub/i); + // Canonical UX wraps the rejected subcommand in quotes: + // `unknown wallet subcommand: "bogus-sub"`. + expect(out).toMatch(/unknown wallet subcommand:\s*"?bogus-sub"?/i); }); }); @@ -311,7 +311,9 @@ describe.skipIf(integrationSkip)( } expect(init.status).toBe(0); - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + // Canonical UX init emits a labelled identity block: + // ` directAddress : DIRECT://...` + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); expect(match, `directAddress not in alice init:\n${init.stdout}`).toBeTruthy(); directAddrAlice = match![1]!; @@ -334,7 +336,7 @@ describe.skipIf(integrationSkip)( } expect(init.status).toBe(0); - const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/); expect(match, `directAddress not in bob init:\n${init.stdout}`).toBeTruthy(); directAddrBob = match![1]!; diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index 47bc9a6..fa107ee 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -2,6 +2,7 @@ * Shared helpers for sphere-cli integration tests against real infrastructure. */ +import { expect } from 'vitest'; import { spawn, spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { mkdtempSync, @@ -286,3 +287,43 @@ export const PUBLIC_TESTNET = { * Allows CI to opt out via `SKIP_INTEGRATION=1`. */ export const integrationSkip = process.env['SKIP_INTEGRATION'] === '1'; + +/** Escape regex metachars so a runtime string can be embedded in a regex. */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Assert that `out` contains the help-block "Usage:" line for `cmdName`. + * + * Help blocks emit `Usage: npm run cli -- [args...]` after PR #33's + * canonical-UX cutover (was `Usage: [args...]`). Folding both forms + * into a single helper means a future format change (e.g. dropping the + * `npm run cli --` shim when sphere ships as a real binary in $PATH) is + * a one-line fix instead of a sweep across every integration suite. + * + * @param out Combined stdout+stderr from `runSphere(...)`. + * @param cmdName Legacy command name (e.g. `'invoice-close'`, + * `'wallet create'`). Matched literally — regex + * metacharacters are escaped. Internal whitespace is + * accepted as `\s+` so both `wallet create` and + * `wallet\screate` match the same line. + * @param positional Optional positional pinned to follow the command + * (e.g. `''`, `''`). Used by guards that + * catch a refactor moving the arg check below the + * wallet load — pins the FULL usage line, not just the + * command echo. + */ +export function expectUsageHint( + out: string, + cmdName: string, + positional?: string, +): void { + // Allow `\s+` between space-separated command parts so `wallet create` + // and `wallet\s\screate` both match. Internal whitespace in cmdName is + // collapsed to `\s+` before escaping. + const parts = cmdName.split(/\s+/).map(escapeRegex).join('\\s+'); + const tail = positional ? `\\s*${escapeRegex(positional)}` : ''; + const re = new RegExp(`Usage:\\s*(?:npm run cli -- )?${parts}${tail}`, 'i'); + expect(out, `expected output to contain Usage hint for '${cmdName}', got:\n${out}`).toMatch(re); +}