|
| 1 | +// Published-artifact smoke for @getanyapi/sdk (npm). |
| 2 | +// |
| 3 | +// Installs are done by scripts/smoke.sh / the workflow: this file resolves the |
| 4 | +// package from the CWD's node_modules (the INSTALLED artifact), NEVER a relative |
| 5 | +// path into the repo source. It exercises the real packaging: exports, types |
| 6 | +// (via the published d.ts at build time is separate; here we assert the runtime |
| 7 | +// surface), and one real production call. |
| 8 | +// |
| 9 | +// Flow: mint an ephemeral key via agentSignup() -> construct AnyAPI -> one live |
| 10 | +// reddit.search call -> assert a well-formed, non-empty envelope. Exit nonzero on |
| 11 | +// any failure with a readable report. |
| 12 | + |
| 13 | +import { createRequire } from "node:module"; |
| 14 | + |
| 15 | +const require = createRequire(`${process.cwd()}/`); |
| 16 | + |
| 17 | +/** Resolve @getanyapi/sdk from the CWD, so we test the installed artifact. */ |
| 18 | +async function loadSdk() { |
| 19 | + // Resolve the package's own entry from the CWD's node_modules. |
| 20 | + const entry = require.resolve("@getanyapi/sdk"); |
| 21 | + const mod = await import(entry); |
| 22 | + return mod; |
| 23 | +} |
| 24 | + |
| 25 | +/** Retry only genuine transport flakiness, never assertion failures. */ |
| 26 | +async function withRetry(label, fn, attempts = 3) { |
| 27 | + let lastErr; |
| 28 | + for (let i = 1; i <= attempts; i++) { |
| 29 | + try { |
| 30 | + return await fn(); |
| 31 | + } catch (err) { |
| 32 | + // A FlakeError is our signal to retry; anything else fails immediately. |
| 33 | + if (!(err instanceof FlakeError) || i === attempts) { |
| 34 | + throw err; |
| 35 | + } |
| 36 | + lastErr = err; |
| 37 | + const backoffMs = 500 * i; |
| 38 | + console.error( |
| 39 | + `[retry] ${label} attempt ${i} failed (${err.message}); retrying in ${backoffMs}ms`, |
| 40 | + ); |
| 41 | + await new Promise((r) => setTimeout(r, backoffMs)); |
| 42 | + } |
| 43 | + } |
| 44 | + throw lastErr; |
| 45 | +} |
| 46 | + |
| 47 | +/** Marks a transient transport error worth retrying. */ |
| 48 | +class FlakeError extends Error {} |
| 49 | + |
| 50 | +/** True when an error looks like transient network trouble. */ |
| 51 | +function isTransient(err, sdk) { |
| 52 | + if (err instanceof sdk.ConnectionError || err instanceof sdk.TimeoutError) { |
| 53 | + return true; |
| 54 | + } |
| 55 | + if (err instanceof sdk.RateLimitedError) { |
| 56 | + return true; |
| 57 | + } |
| 58 | + const msg = String(err && err.message).toLowerCase(); |
| 59 | + return ( |
| 60 | + msg.includes("fetch failed") || |
| 61 | + msg.includes("network") || |
| 62 | + msg.includes("econnreset") || |
| 63 | + msg.includes("timed out") |
| 64 | + ); |
| 65 | +} |
| 66 | + |
| 67 | +function assert(cond, message) { |
| 68 | + if (!cond) { |
| 69 | + throw new Error(`assertion failed: ${message}`); |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +/** Extract the posts array from either a bare or found-data run envelope. */ |
| 74 | +function postsOf(res) { |
| 75 | + const output = res && res.output; |
| 76 | + if (output && typeof output === "object" && "found" in output) { |
| 77 | + return output.found && output.data ? output.data.posts : undefined; |
| 78 | + } |
| 79 | + return output ? output.posts : undefined; |
| 80 | +} |
| 81 | + |
| 82 | +async function main() { |
| 83 | + const sdk = await loadSdk(); |
| 84 | + |
| 85 | + // Assert the packaged surface exports the symbols consumers rely on. A missing |
| 86 | + // export here is exactly the packaging bug this smoke exists to catch. |
| 87 | + for (const name of ["AnyAPI", "agentSignup", "unwrap", "AnyAPIError"]) { |
| 88 | + assert(typeof sdk[name] !== "undefined", `export "${name}" is missing from @getanyapi/sdk`); |
| 89 | + } |
| 90 | + assert(typeof sdk.AnyAPI === "function", "AnyAPI is not a constructor"); |
| 91 | + assert(typeof sdk.agentSignup === "function", "agentSignup is not a function"); |
| 92 | + |
| 93 | + // 1. Mint an ephemeral, capped key. No stored secret. |
| 94 | + const signup = await withRetry("agentSignup", async () => { |
| 95 | + try { |
| 96 | + return await sdk.agentSignup({ label: "sdk-published-smoke" }); |
| 97 | + } catch (err) { |
| 98 | + if (isTransient(err, sdk)) throw new FlakeError(err.message); |
| 99 | + throw err; |
| 100 | + } |
| 101 | + }); |
| 102 | + assert(typeof signup.secret === "string" && signup.secret.length > 0, "signup.secret is empty"); |
| 103 | + assert(typeof signup.capUsd === "number", "signup.capUsd is not a number"); |
| 104 | + console.log(`[ok] minted key (capUsd=$${signup.capUsd})`); |
| 105 | + |
| 106 | + // 2. Construct a client with the minted secret. |
| 107 | + const client = new sdk.AnyAPI({ apiKey: signup.secret }); |
| 108 | + |
| 109 | + // 3. One real, cheap, live data call. reddit.search is $0.001 and non-mock. |
| 110 | + // If the freshly minted cap blocks it (402), fall back to the cheapest |
| 111 | + // known live SKU that returns a real 200. |
| 112 | + let sku = "reddit.search"; |
| 113 | + let res; |
| 114 | + try { |
| 115 | + res = await withRetry("reddit.search", async () => { |
| 116 | + try { |
| 117 | + return await client.reddit.search({ query: "mechanical keyboard", sort: "top" }); |
| 118 | + } catch (err) { |
| 119 | + if (isTransient(err, sdk)) throw new FlakeError(err.message); |
| 120 | + throw err; |
| 121 | + } |
| 122 | + }); |
| 123 | + } catch (err) { |
| 124 | + if (err instanceof sdk.InsufficientBalanceError) { |
| 125 | + // Cap/credit too small for reddit.search: fall back to the cheapest live SKU. |
| 126 | + sku = "reddit.subreddit_details"; |
| 127 | + console.error(`[fallback] reddit.search blocked by cap (402); trying ${sku}`); |
| 128 | + res = await withRetry(sku, async () => { |
| 129 | + try { |
| 130 | + return await client.reddit.subredditDetails({ subreddit: "programming" }); |
| 131 | + } catch (e) { |
| 132 | + if (isTransient(e, sdk)) throw new FlakeError(e.message); |
| 133 | + throw e; |
| 134 | + } |
| 135 | + }); |
| 136 | + } else { |
| 137 | + throw err; |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + // 4. Assert the envelope is well-formed and the call was a real success. |
| 142 | + assert(res && typeof res === "object", "no response object"); |
| 143 | + assert(res.provider === "AnyAPI", `provider must be "AnyAPI", got ${JSON.stringify(res.provider)}`); |
| 144 | + assert(typeof res.costUsd === "number" && res.costUsd >= 0, "costUsd missing/invalid (not a real charged call)"); |
| 145 | + |
| 146 | + if (sku === "reddit.search") { |
| 147 | + const posts = postsOf(res); |
| 148 | + assert(Array.isArray(posts), "output.posts is not an array"); |
| 149 | + assert(posts.length > 0, "output.posts is empty (no real data returned)"); |
| 150 | + const first = posts[0]; |
| 151 | + assert(typeof first.title === "string" && first.title.length > 0, "first post has no title"); |
| 152 | + assert(typeof first.subreddit === "string", "first post has no subreddit"); |
| 153 | + console.log( |
| 154 | + `[ok] ${sku} -> ${posts.length} posts, costUsd=$${res.costUsd}, first="${first.title.slice(0, 60)}"`, |
| 155 | + ); |
| 156 | + } else { |
| 157 | + // Fallback SKU: found-data envelope with a typed subreddit record. |
| 158 | + const data = sdk.unwrap(res); |
| 159 | + assert(data && typeof data.name === "string" && data.name.length > 0, "subreddit details missing name"); |
| 160 | + console.log(`[ok] ${sku} -> r/${data.name}, costUsd=$${res.costUsd}`); |
| 161 | + } |
| 162 | + |
| 163 | + console.log(`PASS npm @getanyapi/sdk: live ${sku} call succeeded against production.`); |
| 164 | +} |
| 165 | + |
| 166 | +main().catch((err) => { |
| 167 | + console.error(`FAIL npm @getanyapi/sdk: ${err && err.message ? err.message : err}`); |
| 168 | + if (err && err.stack) console.error(err.stack); |
| 169 | + process.exit(1); |
| 170 | +}); |
0 commit comments