diff --git a/index.ts b/index.ts index 383fb05..24c7dcc 100644 --- a/index.ts +++ b/index.ts @@ -9,7 +9,16 @@ process.on('warning', (warning) => { }); import 'dotenv/config'; +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'net'; import { Command } from 'commander'; + +// Node's happy-eyeballs gives each address-family connect attempt 250 ms by +// default. That is shorter than a single round trip to a far-away API host +// (e.g. api.raster.art from Asia is ~250 ms RTT), so on networks with broken +// IPv6 the IPv4 attempt gets cut off mid-handshake, the IPv6 fallback has no +// route, and fetch fails or stalls intermittently (#97). 2.5 s per attempt is +// still fast-failing but no longer races the speed of light. +setDefaultAutoSelectFamilyAttemptTimeout(2500); import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { setupCommand } from './src/commands/setup'; diff --git a/src/commands/find.ts b/src/commands/find.ts index fbcad74..a59811c 100644 --- a/src/commands/find.ts +++ b/src/commands/find.ts @@ -20,6 +20,7 @@ import { listArtistArtworks, resolveAddressToArtist, formatSummaryLine, + RasterUnreachableError, } from '../utilities/raster-client'; import type { RasterArtworkSummary, RasterArtworkRow } from '../utilities/raster-client'; import { castPlaylist } from '../utilities/playlist-cast'; @@ -204,6 +205,17 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions): chalk.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`) ); + // Previously-unseen tokens make the indexer warm renditions by polling — + // minutes of wall time on a large series. Say so upfront (the --limit + // warming hint used to appear only after a failure) and render per-batch + // progress so a long index never reads as a hang. + if (tokens.length > 10) { + console.log( + chalk.dim( + ' First-time tokens can take the indexer a while to warm; use `--limit 5` for a faster first pass.' + ) + ); + } // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item // display seconds), not concurrency — concurrency is hardcoded inside. // Omit it for auto timing: video/audio items carry no duration and play @@ -213,7 +225,15 @@ async function runResolvedTarget(target: ResolvedTarget, options: FindOptions): chain: t.chain, contractAddress: t.contract, tokenId: t.tokenId, - })) + })), + undefined, + // Unconditional: suppressing the done===total call meant a one-batch + // index (including the recommended --limit 5 warm-up) printed nothing + // and multi-batch runs never showed completion. The final N/N line IS + // the "indexing finished" signal. + (done: number, total: number) => { + console.log(chalk.dim(` ${done}/${total} tokens indexed...`)); + } ); // FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry @@ -284,14 +304,26 @@ async function resolveTarget( return resolveCoords(parsed.coords); } if (parsed.kind === 'ff-url') { - if (parsed.urlKind === 'show') { + // Shows and series both resolve through the source-resolver's Feral File + // site module, which enumerates exactly the tokens the page describes + // (series slug → /api/series → /api/artworks?seriesID). The old series + // path resolved ONE token via ff-marketplace and then asked Raster, which + // expands to the token's parent artwork — so a single-edition series page + // (e.g. a Display Edition) surprisingly built the sibling collection's + // full token list. A series URL means the user pointed at a specific + // series; build exactly that. Artwork URLs keep the coords→Raster path: + // a single-token page carries no series intent to preserve. + if (parsed.urlKind === 'show' || parsed.urlKind === 'series') { + const label = parsed.urlKind === 'show' ? 'show' : 'series'; const target = await resolveTokenListInput( input, resolverLimitFromOption(limitOption), - `Feral File show ${parsed.identifier}` + `Feral File ${label} ${parsed.identifier}` ); if (target === null) { - throw new Error(`Feral File: no supported tokens found for show "${parsed.identifier}".`); + throw new Error( + `Feral File: no supported tokens found for ${label} "${parsed.identifier}".` + ); } return target; } @@ -410,9 +442,24 @@ async function resolveTokenListInput( /** * Resolve on-chain coords to a target. If Raster doesn't index this token, * fall back to single-token mode so we still build something playable. + * + * Raster being unreachable gets the same fallback: it only enriches the find + * (series enumeration, artist labels) — the coords in hand are enough to + * build a playable single-token playlist, and a network blip on one optional + * dependency must not kill the whole command (#97). */ async function resolveCoords(coords: TokenCoords): Promise { - const summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + let summary: RasterArtworkSummary | null; + try { + summary = await resolveTokenToArtwork(coords.chain, coords.contract, coords.tokenId); + } catch (error) { + if (!(error instanceof RasterUnreachableError)) { + throw error; + } + console.log(chalk.yellow(` ${error.message}`)); + console.log(chalk.dim(' Continuing without Raster — building a one-item playlist.')); + return { kind: 'single', coords }; + } if (summary === null) { return { kind: 'single', coords }; } diff --git a/src/utilities/ab-marketplace.ts b/src/utilities/ab-marketplace.ts index 6844f07..a4ece57 100644 --- a/src/utilities/ab-marketplace.ts +++ b/src/utilities/ab-marketplace.ts @@ -11,6 +11,7 @@ * direct GraphQL path is the right shape. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -43,7 +44,7 @@ export async function resolveArtBlocksCollection(slug: string): Promise { const normalizedArtworkId = artworkId.trim(); if (!normalizedArtworkId) { diff --git a/src/utilities/ff-marketplace.ts b/src/utilities/ff-marketplace.ts index 7faf645..29c524e 100644 --- a/src/utilities/ff-marketplace.ts +++ b/src/utilities/ff-marketplace.ts @@ -14,6 +14,7 @@ * Show URLs are rejected: a single exhibition spans multiple series, which * is wider than v1 supports. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { FeralFileUrlKind, TokenCoords } from '@feralfile/source-resolver'; import { resolveFeralFileArtwork } from './feral-file-artwork'; @@ -39,7 +40,7 @@ interface ArtworksByQueryResponse { async function ffFetch(path: string): Promise { const url = `${FF_API_BASE}${path}`; logger.debug(`[FF API] GET ${url}`); - const response = await fetch(url, { + const response = await fetchWithTimeout(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, }); if (!response.ok) { diff --git a/src/utilities/ff1-compatibility.ts b/src/utilities/ff1-compatibility.ts index 062c197..1f6e97e 100644 --- a/src/utilities/ff1-compatibility.ts +++ b/src/utilities/ff1-compatibility.ts @@ -2,6 +2,7 @@ * FF1 device compatibility helpers for command preflight checks. */ +import { fetchWithTimeout } from './http'; import { getFF1DeviceConfig } from '../config'; import { findConfiguredDeviceIndex } from './device-lookup'; import type { FF1Device, FF1DeviceConfig } from '../types'; @@ -124,7 +125,10 @@ export async function assertFF1CommandCompatibility( command: FF1Command, options: CompatibilityCheckOptions = {} ): Promise { - const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis); + // Default probe fetch rides the shared deadline: a device that accepts + // the TCP connection but never answers must not hang `ff-cli ssh`. + const fetchFn = + options.fetchFn || ((input: string, init?: RequestInit) => fetchWithTimeout(input, init ?? {})); const policy = getCommandPolicy(command); const versionResult = await detectFF1VersionSafely( diff --git a/src/utilities/ff1-device.ts b/src/utilities/ff1-device.ts index 9836825..842d89e 100644 --- a/src/utilities/ff1-device.ts +++ b/src/utilities/ff1-device.ts @@ -3,6 +3,7 @@ * Handles sending DP1 playlists to FF1 devices via the Relayer API */ +import { defaultDeadlineFetch } from './http'; import * as logger from '../logger'; import { getFF1RelayerConfig } from '../config'; import type { FF1Device, Playlist } from '../types'; @@ -193,7 +194,9 @@ export async function sendPlaylistToDevice( overrides: FF1DeviceDependencies = {} ): Promise { const dependencies: DeliveryDependencies = { - fetchFn: overrides.fetchFn ?? globalThis.fetch.bind(globalThis), + // Production default rides the shared deadline; injected test fetches + // are preserved untouched (#101 review). + fetchFn: overrides.fetchFn ?? defaultDeadlineFetch, getTopicIdFn: overrides.getTopicIdFn ?? getTopicId, getRelayerConfigFn: overrides.getRelayerConfigFn ?? getFF1RelayerConfig, waitFn: overrides.waitFn ?? waitForRetry, diff --git a/src/utilities/ff1-relayer.ts b/src/utilities/ff1-relayer.ts index 5afcd3f..85bb2ba 100644 --- a/src/utilities/ff1-relayer.ts +++ b/src/utilities/ff1-relayer.ts @@ -1,3 +1,4 @@ +import { defaultDeadlineFetch } from './http'; import type { Playlist } from '../types'; export interface RelayerCastResult { @@ -39,7 +40,8 @@ export async function sendPlaylistViaRelayer(input: { playlist: Playlist; fetchFn?: typeof fetch; }): Promise { - const fetchFn = input.fetchFn ?? globalThis.fetch.bind(globalThis); + // Production default rides the shared deadline (#101 review). + const fetchFn = input.fetchFn ?? defaultDeadlineFetch; let url: URL; try { url = new URL('/api/cast', input.baseUrl); diff --git a/src/utilities/fxhash-marketplace.ts b/src/utilities/fxhash-marketplace.ts index 85ee1a8..b487232 100644 --- a/src/utilities/fxhash-marketplace.ts +++ b/src/utilities/fxhash-marketplace.ts @@ -15,6 +15,7 @@ * GraphQL endpoint and surface as "slug not found". */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -40,7 +41,7 @@ interface FxhashObjktResponse { export async function resolveFxhashIteration(slug: string): Promise { logger.debug(`[fxhash] Resolving iteration slug "${slug}"`); const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }'; - const response = await fetch(FXHASH_GRAPHQL, { + const response = await fetchWithTimeout(FXHASH_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { slug } }), @@ -110,7 +111,7 @@ export async function resolveFxhashProject(slug: string): Promise { ' }' + ' }' + '}'; - const response = await fetch(FXHASH_GRAPHQL, { + const response = await fetchWithTimeout(FXHASH_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { slug } }), diff --git a/src/utilities/handoff-client.ts b/src/utilities/handoff-client.ts index 3f1b804..aa9f31e 100644 --- a/src/utilities/handoff-client.ts +++ b/src/utilities/handoff-client.ts @@ -1,3 +1,4 @@ +import { defaultDeadlineFetch } from './http'; import { webcrypto } from 'node:crypto'; import { HANDOFF_ALGORITHM, @@ -109,7 +110,8 @@ export class TopicHandoffReceiver { fetchFn?: typeof fetch; } = {} ): Promise { - const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + // Production default rides the shared deadline (#101 review). + const fetchFn = options.fetchFn ?? defaultDeadlineFetch; const baseUrl = brokerBaseUrl(options.baseUrl); const keyPair = await generateHandoffKeyPair(); const publicKey = await exportHandoffPublicJwk(keyPair.publicKey); diff --git a/src/utilities/http.ts b/src/utilities/http.ts new file mode 100644 index 0000000..e9d2b81 --- /dev/null +++ b/src/utilities/http.ts @@ -0,0 +1,44 @@ +/** + * Shared fetch-with-timeout for every outbound HTTP call in the CLI. + * + * Bare `fetch` has no deadline: a stalled connect or an unanswered request + * hangs the command silently — the failure mode agents can least diagnose, + * and the one behind #97. Every resolver and indexer call goes through here + * so "silent hang" is structurally impossible, not per-call-site discipline. + * + * Callers that pass their own `signal` keep it (composed with the deadline + * via AbortSignal.any), so cancellation semantics are never silently + * replaced by a timeout. + */ + +export const DEFAULT_HTTP_TIMEOUT_MS = 20_000; + +export function fetchWithTimeout( + input: string | URL | Request, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS +): Promise { + const deadline = AbortSignal.timeout(timeoutMs); + const signals: AbortSignal[] = [deadline]; + if (init.signal) { + signals.push(init.signal); + } else if (input instanceof Request && input.signal) { + // A Request input carries its own abort signal; passing only the + // deadline in init would silently REPLACE it (init wins over the + // request's signal in fetch), stripping the caller's cancellation. + signals.push(input.signal); + } + const signal = signals.length > 1 ? AbortSignal.any(signals) : deadline; + return fetch(input, { ...init, signal }); +} + +/** + * Drop-in `fetch` for production defaults of injectable-fetch modules: + * identical signature, deadline attached. Modules that accept a `fetchFn` + * override default to this instead of bare `globalThis.fetch`, so tests + * keep full injection control while production can never hang. + */ +export const defaultDeadlineFetch: typeof fetch = ( + input: string | URL | Request, + init?: RequestInit +) => fetchWithTimeout(input, init ?? {}); diff --git a/src/utilities/neort-marketplace.ts b/src/utilities/neort-marketplace.ts index 0aaf1cd..d16df3e 100644 --- a/src/utilities/neort-marketplace.ts +++ b/src/utilities/neort-marketplace.ts @@ -23,6 +23,7 @@ * reject when `id` is empty. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { USER_AGENT } from './user-agent'; @@ -58,7 +59,7 @@ export interface NeortArt { */ export async function resolveNeortArt(id: string): Promise { logger.debug(`[neort] Resolving art id "${id}"`); - const response = await fetch(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, { + const response = await fetchWithTimeout(`${NEORT_API_BASE}/art/${encodeURIComponent(id)}`, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, }); if (!response.ok) { diff --git a/src/utilities/nft-indexer.js b/src/utilities/nft-indexer.js index 6175017..7b3efe6 100644 --- a/src/utilities/nft-indexer.js +++ b/src/utilities/nft-indexer.js @@ -5,6 +5,7 @@ */ const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql'; +const { fetchWithTimeout } = require('./http'); const logger = require('../logger'); const { applyItemTiming } = require('./playlist-builder'); @@ -196,7 +197,7 @@ async function queryTokens(params = {}) { logger.debug('[NFT Indexer] Querying tokens:', { token_cids, owners, limit, offset }); logger.debug('[NFT Indexer] GraphQL query:', query); - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query }), @@ -675,7 +676,7 @@ async function getNFTTokenInfoSingle(params, duration, options = {}) { * @param {number} duration - Display duration in seconds * @returns {Promise} Array of DP1 items */ -async function getNFTTokenInfoBatch(tokens, duration) { +async function getNFTTokenInfoBatch(tokens, duration, onProgress) { logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`); logger.debug('[NFT Indexer] Batch tokens:', tokens); @@ -708,6 +709,19 @@ async function getNFTTokenInfoBatch(tokens, duration) { logger.info(`[NFT Indexer] Batch complete: ${successful} success, ${failed} failed`); results.push(...batchResults); + + // Indexing previously-unseen tokens can take minutes (the indexer warms + // renditions by polling); without user-visible progress the CLI reads as + // hung. logger.info above is verbose-gated, so callers that face a human + // pass onProgress to render it. Errors in the callback must never break + // the batch — reporting is strictly best-effort. + if (typeof onProgress === 'function') { + try { + onProgress(results.length, tokens.length); + } catch { + /* progress display is best-effort */ + } + } } logger.info(`[NFT Indexer] ✓ Batch processing complete: ${results.length} total results`); @@ -788,7 +802,7 @@ async function triggerIndexingAsync(chain, contractAddress, tokenId) { const headers = { 'Content-Type': 'application/json' }; - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query: mutation, variables }), @@ -852,7 +866,7 @@ async function queryJobStatus(jobId) { const variables = { job_id: id }; const headers = { 'Content-Type': 'application/json' }; - const response = await fetch(GRAPHQL_ENDPOINT, { + const response = await fetchWithTimeout(GRAPHQL_ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query, variables }), diff --git a/src/utilities/objkt-marketplace.ts b/src/utilities/objkt-marketplace.ts index 49ebd34..0bd81df 100644 --- a/src/utilities/objkt-marketplace.ts +++ b/src/utilities/objkt-marketplace.ts @@ -10,6 +10,7 @@ * when an alias segment is detected. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { USER_AGENT } from './user-agent'; @@ -30,7 +31,7 @@ interface FaPathResponse { export async function resolveObjktAlias(alias: string): Promise { logger.debug(`[Objkt] Resolving alias "${alias}"`); const query = 'query ($path: String!) { fa(where: { path: { _eq: $path } }) { contract } }'; - const response = await fetch(OBJKT_GRAPHQL, { + const response = await fetchWithTimeout(OBJKT_GRAPHQL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT }, body: JSON.stringify({ query, variables: { path: alias } }), diff --git a/src/utilities/opensea-marketplace.ts b/src/utilities/opensea-marketplace.ts index 92dc265..157da4f 100644 --- a/src/utilities/opensea-marketplace.ts +++ b/src/utilities/opensea-marketplace.ts @@ -24,6 +24,7 @@ * a layout change degrades to a one-step workaround, not a dead end. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import type { TokenCoords } from '@feralfile/source-resolver'; import { USER_AGENT } from './user-agent'; @@ -51,9 +52,12 @@ const FALLBACK_HINT = */ export async function resolveOpenSeaCollection(slug: string): Promise { logger.debug(`[OpenSea] Resolving collection slug "${slug}"`); - const response = await fetch(`https://opensea.io/collection/${encodeURIComponent(slug)}`, { - headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' }, - }); + const response = await fetchWithTimeout( + `https://opensea.io/collection/${encodeURIComponent(slug)}`, + { + headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' }, + } + ); if (!response.ok) { throw new Error( `OpenSea collection page returned ${response.status} ${response.statusText}. ` + FALLBACK_HINT diff --git a/src/utilities/playlist-source.ts b/src/utilities/playlist-source.ts index 8587aa5..b5595dc 100644 --- a/src/utilities/playlist-source.ts +++ b/src/utilities/playlist-source.ts @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from './http'; import { promises as fs } from 'fs'; import type { Playlist } from '../types'; @@ -48,7 +49,7 @@ export async function loadPlaylistSource(source: string): Promise = { export type IndexerChain = 'ethereum' | 'tezos'; +/** + * Raster could not be reached at the network level (connect failure, broken + * IPv6 path, timeout). Distinct from API-level errors so callers that hold + * usable token coordinates can degrade gracefully instead of dying — Raster + * only enriches a find, it is not required to build a playable playlist. + */ +export class RasterUnreachableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RasterUnreachableError'; + } +} + export interface RasterArtist { id: string; name: string; @@ -118,16 +140,46 @@ async function rasterQuery(query: string, variables: Record) if (apiKey) { headers['x-api-key'] = apiKey; } - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify({ query, variables }), - }); + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(RASTER_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Undici's "fetch failed" carries no endpoint and no cause in its + // message — name the host and the real failure so the error is + // actionable, and let callers detect network failure by type. + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError(`Raster API unreachable (${endpoint}): ${detail}`); + } if (!response.ok) { - const body = await response.text(); + // Body text is best-effort context; the HTTP status alone is the error. + // A body read that rejects here must not mask the status — and an HTTP + // error is an API-level failure, deliberately NOT RasterUnreachableError. + const body = await response.text().catch(() => ''); throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`); } - const payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + // The network-error boundary extends through body consumption: a response + // whose stream stalls past the headers or terminates mid-body rejects + // HERE, not in fetch() above. Those must still surface as + // RasterUnreachableError or resolveCoords cannot perform its promised + // single-token fallback. (A 200 whose body cannot be read or parsed is + // unusable either way — falling back is right even for server-side + // garbage.) + let payload: { data?: T; errors?: Array<{ message: string }> }; + try { + payload = (await response.json()) as { data?: T; errors?: Array<{ message: string }> }; + } catch (error) { + const cause = (error as { cause?: { message?: string } }).cause; + const detail = cause?.message ?? (error as Error).message; + throw new RasterUnreachableError( + `Raster API unreachable (${endpoint}): response body failed: ${detail}` + ); + } if (payload.errors && payload.errors.length > 0) { throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`); } diff --git a/src/utilities/ssh-access.ts b/src/utilities/ssh-access.ts index be5467d..df966ca 100644 --- a/src/utilities/ssh-access.ts +++ b/src/utilities/ssh-access.ts @@ -2,6 +2,7 @@ * SSH access control for FF1 devices. */ +import { fetchWithTimeout } from './http'; import * as logger from '../logger'; import { assertFF1CommandCompatibility, resolveConfiguredDevice } from './ff1-compatibility'; @@ -100,7 +101,7 @@ export async function sendSshAccessCommand({ headers['API-KEY'] = device.apiKey; } - const response = await fetch(apiUrl, { + const response = await fetchWithTimeout(apiUrl, { method: 'POST', headers, body: JSON.stringify(requestBody), diff --git a/src/utilities/verse-marketplace.ts b/src/utilities/verse-marketplace.ts index 9ece5f1..7038009 100644 --- a/src/utilities/verse-marketplace.ts +++ b/src/utilities/verse-marketplace.ts @@ -1,3 +1,4 @@ +import { fetchWithTimeout } from './http'; import type { TokenCoords } from '@feralfile/source-resolver'; /** @@ -14,7 +15,7 @@ import type { TokenCoords } from '@feralfile/source-resolver'; */ export async function resolveVerseSeries(slug: string): Promise { const url = `https://verse.works/series/${encodeURIComponent(slug)}`; - const response = await fetch(url); + const response = await fetchWithTimeout(url); if (!response.ok) { throw new Error(`Verse series lookup failed for ${slug}: ${response.status}`); } diff --git a/tests/find-command.test.ts b/tests/find-command.test.ts index 127b853..81e27c8 100644 --- a/tests/find-command.test.ts +++ b/tests/find-command.test.ts @@ -47,6 +47,13 @@ before(async () => { variables: Record; }; res.setHeader('Content-Type', 'application/json'); + if (destroyResponseBody) { + // Headers out, body terminated mid-stream: fetch() resolves, the + // body read rejects — the #98-review failure mode. + res.write('{"data": {'); + res.destroy(); + return; + } res.end(JSON.stringify(handler(query, variables ?? {}))); }); }); @@ -64,8 +71,11 @@ after(() => { server.close(); }); +let destroyResponseBody = false; + beforeEach(() => { handler = () => ({ errors: [{ message: 'no handler installed for this test' }] }); + destroyResponseBody = false; }); interface RunResult { @@ -278,6 +288,19 @@ describe('find command — Raster series flow (mock GraphQL server)', () => { assert.match(result.stdout, /Build playlist with 1 token\?/); assert.match(result.stdout, /Cancelled\./); }); + + test('Raster body terminating mid-stream → warns and degrades to one-item playlist (#98 review)', async () => { + // fetch() succeeds (headers arrive), the body read rejects. The typed + // RasterUnreachableError must survive to resolveCoords so the find flow + // degrades instead of dying — the whole point of the boundary fix. + destroyResponseBody = true; + const result = await runFind([`ethereum:${VALID_ETH_CONTRACT}:1`], { stdin: 'n\n' }); + assert.equal(result.code, 0); + assert.match(result.stdout, /Raster API unreachable/); + assert.match(result.stdout, /Continuing without Raster — building a one-item playlist/); + assert.match(result.stdout, /Build playlist with 1 token\?/); + assert.match(result.stdout, /Cancelled\./); + }); }); describe('find command — resolver-backed token-list flow', () => { @@ -286,11 +309,12 @@ describe('find command — resolver-backed token-list flow', () => { const superRareCollectionUrl = 'https://superrare.com/collection/0x1234567890123456789012345678901234567890'; const feralFileShowUrl = 'https://feralfile.com/exhibitions/shows/mock-show'; + const feralFileSeriesUrl = 'https://feralfile.com/exhibitions/series/mock-series'; const dir = mkdtempSync(join(tmpdir(), 'ff-find-token-list-')); const originalCwd = process.cwd(); const resolverCalls: Array<{ input: string; options: { limit: number } }> = []; const promptQuestions: string[] = []; - const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's']; + const promptAnswers = ['yes', 's', 'yes', 's', 'yes', 's', 'yes', 's']; const indexedBatches: unknown[][] = []; const stdoutWrites: string[] = []; const stderrWrites: string[] = []; @@ -321,6 +345,23 @@ describe('find command — resolver-backed token-list flow', () => { reason: 'mock resolver miss', }; } + // Series pages resolve to exactly the tokens the page describes — + // one here, mirroring a single-edition series (the case the old + // ff-marketplace→Raster path expanded to a sibling collection). + if (input === feralFileSeriesUrl) { + return { + kind: 'tokens', + title: 'Mock Feral File Series', + coords: [ + { + chain: 'ethereum', + contract: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + hasMore: false, + }; + } const isSuperRareCollection = input === superRareCollectionUrl; const isFeralFileShow = input === feralFileShowUrl; return { @@ -537,6 +578,56 @@ describe('find command — resolver-backed token-list flow', () => { stderrWrites.join(''), /Feral File: no supported tokens found for show "mock-show"/ ); + + // Series URLs route through the same resolver token-list path as shows + // (never ff-marketplace→Raster, which expanded a single-edition series + // to its parent artwork's full token list). The mock Raster server + // seeing zero traffic is asserted implicitly: a Raster call would hit + // this test's GraphQL handler and fail the deepEqual on indexedBatches. + resolverCalls.length = 0; + promptQuestions.length = 0; + indexedBatches.length = 0; + stdoutWrites.length = 0; + stderrWrites.length = 0; + resolverMissInputs.delete(feralFileShowUrl); + + await findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }); + + assert.deepEqual(resolverCalls, [ + { + input: feralFileSeriesUrl, + options: { limit: 1 }, + }, + ]); + assert.deepEqual(indexedBatches, [ + [ + { + chain: 'ethereum', + contractAddress: '0x1234567890123456789012345678901234567890', + tokenId: '21', + }, + ], + ]); + assert.match(stdoutWrites.join(''), /Mock Feral File Series/); + + resolverCalls.length = 0; + stderrWrites.length = 0; + resolverMissInputs.add(feralFileSeriesUrl); + + await assert.rejects( + () => + findCommand.parseAsync(['node', 'find', feralFileSeriesUrl, '--limit', '1'], { + from: 'node', + }), + /process\.exit\(1\)/ + ); + + assert.match( + stderrWrites.join(''), + /Feral File: no supported tokens found for series "mock-series"/ + ); } finally { process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; diff --git a/tests/find-resolvers.test.ts b/tests/find-resolvers.test.ts index 63bc1cc..e7c56f7 100644 --- a/tests/find-resolvers.test.ts +++ b/tests/find-resolvers.test.ts @@ -696,3 +696,342 @@ describe('resolveFeralFileToken', () => { ); }); }); + +describe('rasterQuery network resilience (#97)', () => { + test('connect failure → RasterUnreachableError naming the endpoint and cause', async () => { + const mock = (async () => { + // Undici's opaque envelope: message says nothing, cause has the truth. + throw Object.assign(new TypeError('fetch failed'), { + cause: new Error('connect ETIMEDOUT 65.109.41.181:443'), + }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /api\.raster\.art/); + assert.match(error.message, /ETIMEDOUT/); + return true; + } + ); + }); + + test('abort (timeout) → RasterUnreachableError, not a hang or bare AbortError', async () => { + const mock = (async () => { + throw Object.assign(new DOMException('This operation was aborted', 'AbortError'), {}); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + return true; + } + ); + }); + + test('fetch carries an abort signal so a stalled request cannot hang forever', async () => { + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ data: { tokenByRef: null } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')); + assert.equal(sawSignal, true); + }); +}); + +describe('rasterQuery body-read failures (#98 review)', () => { + test('body that terminates mid-stream → RasterUnreachableError (fallback stays reachable)', async () => { + const mock = (async () => { + // Headers arrive fine; the body stream errors — response.json() rejects + // outside fetch()'s own rejection path. + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + assert.equal(error.name, 'RasterUnreachableError'); + assert.match(error.message, /response body failed/); + return true; + } + ); + }); + + test('HTTP error with unreadable body keeps the status error, not unreachable', async () => { + const mock = (async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('terminated')); + }, + }); + return new Response(body, { status: 502, statusText: 'Bad Gateway' }); + }) as FetchFn; + await assert.rejects( + () => withMockedFetch(mock, () => resolveTokenToArtwork('ethereum', '0xabc', '1')), + (error: Error) => { + // API-level failure: NOT the network-unreachable type, and the + // status survives even though the body could not be read. + assert.notEqual(error.name, 'RasterUnreachableError'); + assert.match(error.message, /Raster API 502/); + return true; + } + ); + }); +}); + +describe('fetchWithTimeout (shared HTTP deadline)', () => { + test('every resolver-facing fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => fetchWithTimeout('https://example.com')); + assert.equal(sawSignal, true); + }); + + test('caller-supplied signal is composed with the deadline, not replaced', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + const caller = new AbortController(); + let received: AbortSignal | undefined; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + received = init?.signal ?? undefined; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => + fetchWithTimeout('https://example.com', { signal: caller.signal }) + ); + assert.ok(received instanceof AbortSignal); + // Aborting the caller's controller must abort the composed signal. + caller.abort(); + assert.equal(received?.aborted, true); + }); +}); + +describe('device request deadlines (#101 review)', () => { + test('ssh access request carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sendSshAccessCommand } = require('../src/utilities/ssh-access'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require('node:os'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require('node:path'); + + // sendSshAccessCommand resolves its device from the user config, so the + // test must carry its own — a machine with a real ~/.config/ff-cli made + // the original version of this test pass while CI (no config) failed. + // XDG_CONFIG_HOME is read per-call, so a temp config keeps it hermetic + // on every platform AND independent of the developer's real devices. + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-cli-ssh-test-')); + fs.mkdirSync(path.join(configHome, 'ff-cli'), { recursive: true }); + fs.writeFileSync( + path.join(configHome, 'ff-cli', 'config.json'), + JSON.stringify({ + ff1Devices: { devices: [{ name: 'test', host: 'http://127.0.0.1:1111' }] }, + }) + ); + const originalXdg = process.env.XDG_CONFIG_HOME; + + const signals: boolean[] = []; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + signals.push(init?.signal instanceof AbortSignal); + // First call is the compatibility probe (getDeviceStatus), second is + // the sshAccess request itself; this reply shape satisfies both. + return new Response(JSON.stringify({ ok: true, message: { installedVersion: '1.0.21' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + + try { + process.env.XDG_CONFIG_HOME = configHome; + const result = await withMockedFetch(mock, () => + sendSshAccessCommand({ + enabled: true, + publicKey: 'ssh-ed25519 AAAA test', + }) + ); + // The command must have actually reached the device request — a + // config-resolution early-out would pass a naive signal assertion + // by never fetching at all. + assert.equal(result.success, true); + assert.ok(signals.length >= 2, `expected probe + request fetches, saw ${signals.length}`); + assert.ok( + signals.every(Boolean), + 'every device fetch (probe and ssh request) must carry a deadline signal' + ); + } finally { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdg; + } + fs.rmSync(configHome, { recursive: true, force: true }); + } + }); + + test('ff1 compatibility probe default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { assertFF1CommandCompatibility } = require('../src/utilities/ff1-compatibility'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ message: { installedVersion: '1.0.21' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => + assertFF1CommandCompatibility({ name: 'test', host: 'http://127.0.0.1:1111' }, 'sshAccess') + ); + assert.equal(sawSignal, true); + }); +}); + +describe('getNFTTokenInfoBatch progress reporting (#101 review)', () => { + // A rejecting fetch makes every token fail fast (no indexer polling), so + // these tests exercise exactly the batching + progress mechanics. + const rejectingFetch = (async () => { + throw new TypeError('fetch failed'); + }) as FetchFn; + + test('single batch reports the final done===total call', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const calls: Array<[number, number]> = []; + await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch( + [{ chain: 'ethereum', contractAddress: '0xabc', tokenId: '1' }], + undefined, + (done: number, total: number) => calls.push([done, total]) + ) + ); + assert.deepEqual(calls, [[1, 1]]); + }); + + test('multi-batch reports each batch including completion', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const tokens = Array.from({ length: 12 }, (_unused, i) => ({ + chain: 'ethereum', + contractAddress: '0xabc', + tokenId: String(i), + })); + const calls: Array<[number, number]> = []; + await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch(tokens, undefined, (done: number, total: number) => + calls.push([done, total]) + ) + ); + // Concurrency is 10 per batch: 12 tokens → [10/12, 12/12]. + assert.deepEqual(calls, [ + [10, 12], + [12, 12], + ]); + }); + + test('a throwing progress callback never breaks the batch', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getNFTTokenInfoBatch } = require('../src/utilities/nft-indexer'); + const items = await withMockedFetch(rejectingFetch, () => + getNFTTokenInfoBatch( + [{ chain: 'ethereum', contractAddress: '0xabc', tokenId: '1' }], + undefined, + () => { + throw new Error('renderer exploded'); + } + ) + ); + assert.deepEqual(items, []); + }); +}); + +describe('production fetch defaults ride the deadline (#101 review round 2)', () => { + test('defaultDeadlineFetch attaches a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { defaultDeadlineFetch } = require('../src/utilities/http'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => defaultDeadlineFetch('https://example.com')); + assert.equal(sawSignal, true); + }); + + test('feral-file artwork resolver default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { resolveFeralFileArtwork } = require('../src/utilities/feral-file-artwork'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response( + JSON.stringify({ + result: { + id: 'abc', + blockchainStatus: 'settled', + contractAddress: '0xabc', + tokenID: '1', + chain: 'ethereum', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }) as FetchFn; + await withMockedFetch(mock, () => resolveFeralFileArtwork('abc').catch(() => undefined)); + assert.equal(sawSignal, true); + }); + + test('relayer cast default fetch carries a deadline signal', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { sendPlaylistViaRelayer } = require('../src/utilities/ff1-relayer'); + let sawSignal = false; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + sawSignal = init?.signal instanceof AbortSignal; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as FetchFn; + await withMockedFetch(mock, () => + sendPlaylistViaRelayer({ + baseUrl: 'https://relayer.example.com', + topicId: 'topic', + playlist: { dpVersion: '1.1.0', id: 'x', slug: 'x', title: 'x', created: 'x', items: [] }, + }).catch(() => undefined) + ); + assert.equal(sawSignal, true); + }); + + test('Request input keeps its own abort signal composed with the deadline', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fetchWithTimeout } = require('../src/utilities/http'); + const caller = new AbortController(); + let received: AbortSignal | undefined; + const mock = (async (_input: string | URL | Request, init?: RequestInit) => { + received = init?.signal ?? undefined; + return new Response('{}', { status: 200 }); + }) as FetchFn; + await withMockedFetch(mock, () => + fetchWithTimeout(new Request('https://example.com', { signal: caller.signal })) + ); + assert.ok(received instanceof AbortSignal); + caller.abort(); + // The Request's own signal must propagate through the composed signal + // rather than being replaced by the deadline. + assert.equal(received?.aborted, true); + }); +});