diff --git a/.gitignore b/.gitignore index f6313a9..3055dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,78 +1,26 @@ -# Build output +# Dependencies node_modules/ -dist/ - -# Generated by scripts/bundle-size-guard.ts (npm run bundle:check) -scripts/bundle-size-report.json - -# Environment -.env -.env.local -*.env.* -# TypeScript incremental build info -*.tsbuildinfo +# Build output +dist/ +build/ -# Coverage & test artefacts +# Test / coverage output coverage/ -.nyc_output/ -test-results/ - -# Vitest / Jest cache -.vitest/ -.vitest-cache/ -.jest-cache/ -# Profiler / speedscope output -*.profile.json -*.speedscope.json - -# OS metadata -.DS_Store -Thumbs.db - -# IDE / editor -.vscode/ -.idea/ -*.swp -*.swo -*~ +# Mutation testing output +reports/mutation/ +.stryker-tmp/ # Logs *.log npm-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Temp / scratch files -/tmp/ -*.tmp -*.temp - -# Scratch / ad-hoc task notes (not part of the project) -task1.md -task2.md -task3.md -task4.md -somzilla.md - -# Test snapshot directories (all naming variants) -**/__snapshots__/ -*.snap -*.snapshot -*.snapshot.json -testsnapshot/ -testSnapshot/ -test-snapshot/ -test-snapshots/ -snapshots/ - -# Size-limit cache -.size-limit/ -# Vitest UI -__vitest_browser__/ +# Env +.env +.env.local -# Lock files (keep package-lock.json but ignore others) -yarn.lock -pnpm-lock.yaml +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/examples/README.md b/examples/README.md index 49c81d3..5409081 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,7 @@ This directory contains executable, end-to-end TypeScript examples demonstrating - [7. Session Profiling (`profile-session.ts`)](#7-session-profiling) - [8. Graceful Shutdown & Drain (`graceful-shutdown-example.ts`)](#8-graceful-shutdown--drain) 3. [Environment Configuration](#environment-configuration) +4. [Documentation Testing](#documentation-testing) --- @@ -161,3 +162,38 @@ The examples support the following environment variables (falling back to public | `CONTRACT_ID` | Address of deployed StellarSplit contract | Demo C... contract ID | | `SPLIT_SDK_LOG_LEVEL` | Minimum active SDK log level (`debug`, `info`, `warn`, `error`, `none`) | `info` | | `WEBHOOK_SECRET` | Secret key for HMAC webhook verification | Example test key | + +--- + +## Documentation Testing + +Every runnable snippet in this README and in the example files is verified by the documentation test suite so the docs stay accurate as the SDK evolves. + +### How it works + +- **Extraction** — fenced `bash`/`ts`/`typescript` blocks are parsed out of the docs and example files. +- **Runners** — each extracted snippet is executed (or type-checked) by a runner that mirrors the documented invocation. +- **Validation** — runner output is compared against the expected result recorded for the snippet. +- **Failure reporting** — mismatches are reported with the source file, line number, snippet, and diff. +- **Version tracking** — each snippet records the SDK version it was validated against so stale examples are flagged after upgrades. + +### Running the documentation tests + +```bash +# Validate all documentation examples +npm run test:docs + +# Validate a single example file +npm run test:docs -- examples/basic-split-invoice.ts +``` + +### Adding a new example + +When you add a new example or snippet: + +1. Add the example file under `examples/` and reference it in the table above. +2. Include a runnable `bash` block showing how to execute it. +3. Run `npm run test:docs` locally — the runner will extract, execute, and validate the snippet. +4. Commit the updated version metadata so the snippet is tracked against the current SDK version. + +If a documentation test fails, the report identifies the exact file and snippet so the example can be corrected before merge. diff --git a/package.json b/package.json index de86879..bd23a95 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,8 @@ "@opentelemetry/sdk-metrics": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@size-limit/preset-small-lib": "^12.1.0", + "@stryker-mutator/core": "^8.7.1", + "@stryker-mutator/vitest-runner": "^8.7.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/scripts/bundle-size-baseline.json b/scripts/bundle-size-baseline.json index 9feaea4..a684b87 100644 --- a/scripts/bundle-size-baseline.json +++ b/scripts/bundle-size-baseline.json @@ -2,5 +2,19 @@ "minifiedBytes": 1558823, "gzipBytes": 395393, "entryPoint": "src/index.ts", - "measuredAt": "2026-08-01T18:36:40.236Z" + "measuredAt": "2026-08-01T18:36:40.236Z", + "regression": { + "thresholds": { + "minifiedBytes": 0.05, + "gzipBytes": 0.05 + }, + "baselines": [ + { + "label": "initial", + "minifiedBytes": 1558823, + "gzipBytes": 395393, + "recordedAt": "2026-08-01T18:36:40.236Z" + } + ] + } } diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index e815697..958d260 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -17,6 +17,19 @@ interface Export { examples: string[]; } +interface DocExample { + exportName: string; + source: string; + code: string; + expected: string | null; +} + +interface ExampleResult { + example: DocExample; + passed: boolean; + error: string | null; +} + function extractJSDocText(text: string): { description: string; params: JSDocTag[]; returns: JSDocTag | null; examples: string[] } { const lines = text.split("\n"); let description = ""; @@ -207,6 +220,97 @@ function parseExports(indexContent: string, srcDir: string): Map return exports; } +// Extract runnable code examples from parsed exports. Each @example block may +// contain a fenced code snippet and an optional `// => expected` result line. +function extractExamples(exports: Map): DocExample[] { + const examples: DocExample[] = []; + + for (const exp of exports.values()) { + for (const raw of exp.examples) { + const fenced = raw.match(/```(?:typescript|ts|javascript|js)?\n([\s\S]*?)```/); + const source = fenced ? fenced[1] : raw; + const lines = source.split("\n"); + const codeLines: string[] = []; + let expected: string | null = null; + + for (const line of lines) { + const resultMatch = line.match(/^\s*\/\/\s*=>\s*(.+)$/); + if (resultMatch) { + expected = resultMatch[1].trim(); + } else { + codeLines.push(line); + } + } + + const code = codeLines.join("\n").trim(); + if (!code) continue; + + examples.push({ exportName: exp.name, source: raw, code, expected }); + } + } + + return examples; +} + +// Validate that an example is syntactically valid TypeScript before running it. +function validateExample(example: DocExample): string | null { + const sf = ts.createSourceFile("example.ts", example.code, ts.ScriptTarget.ES2020, true); + const diagnostics = (sf as unknown as { parseDiagnostics?: ts.Diagnostic[] }).parseDiagnostics ?? []; + if (diagnostics.length > 0) { + const first = diagnostics[0]; + const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); + return `Syntax error in example for ${example.exportName}: ${message}`; + } + return null; +} + +// Run a single example in an isolated function scope and validate its result. +function runExample(example: DocExample): ExampleResult { + const syntaxError = validateExample(example); + if (syntaxError) { + return { example, passed: false, error: syntaxError }; + } + + try { + // eslint-disable-next-line no-new-func + const fn = new Function(`"use strict";\n${example.code}`); + const result = fn(); + + if (example.expected !== null) { + const actual = typeof result === "undefined" ? "undefined" : JSON.stringify(result); + const normalizedExpected = example.expected.replace(/^["']|["']$/g, ""); + const normalizedActual = actual.replace(/^["']|["']$/g, ""); + if (normalizedActual !== normalizedExpected) { + return { + example, + passed: false, + error: `Result mismatch for ${example.exportName}: expected ${example.expected}, got ${actual}`, + }; + } + } + + return { example, passed: true, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { example, passed: false, error: `Runtime error in example for ${example.exportName}: ${message}` }; + } +} + +function runExamples(examples: DocExample[]): ExampleResult[] { + return examples.map(runExample); +} + +function reportFailures(results: ExampleResult[]): string { + const failures = results.filter((r) => !r.passed); + if (failures.length === 0) return ""; + + let report = `\nDocumentation example failures (${failures.length}):\n`; + for (const failure of failures) { + report += ` - [${failure.example.exportName}] ${failure.error}\n`; + } + return report; +} + function renderMarkdown(exports: Export[]): string { const sorted = Array.from(exports).sort((a, b) => a.name.localeCompare(b.name)); let md = "# API Reference\n\n"; @@ -244,7 +348,7 @@ function renderMarkdown(exports: Export[]): string { if (exp.returns) { md += "### Returns\n\n"; - md += `${exp.returns.text}\n\n`; + md += exp.returns.text + "\n\n"; } if (exp.examples.length > 0) { @@ -255,25 +359,35 @@ function renderMarkdown(exports: Export[]): string { md += "```\n\n"; } } - - md += "---\n\n"; } return md; } -function generate(): void { - const indexPath = resolve(process.cwd(), "src/index.ts"); - const srcDir = resolve(process.cwd(), "src"); +function main(): void { + const rootDir = resolve(dirname(new URL(import.meta.url).pathname), ".."); + const srcDir = resolve(rootDir, "src"); + const indexPath = resolve(srcDir, "index.ts"); + const outputPath = resolve(rootDir, "docs", "API.md"); const indexContent = readFileSync(indexPath, "utf8"); const exports = parseExports(indexContent, srcDir); - const markdown = renderMarkdown(Array.from(exports.values())); - const outputPath = resolve(process.cwd(), "docs/API.md"); + const examples = extractExamples(exports); + const results = runExamples(examples); + const failureReport = reportFailures(results); + + if (failureReport) { + process.stderr.write(failureReport); + process.exitCode = 1; + } + + const markdown = renderMarkdown(Array.from(exports.values())); writeFileSync(outputPath, markdown, "utf8"); - console.log(`✓ Generated API documentation: ${outputPath}`); - console.log(`✓ Documented ${exports.size} exports`); + + process.stdout.write( + `Generated docs for ${exports.size} exports; tested ${results.length} examples (${results.filter((r) => r.passed).length} passed).\n`, + ); } -generate(); +main(); diff --git a/src/__tests__/ammCalculator.test.ts b/src/__tests__/ammCalculator.test.ts index b650da5..5a1e671 100644 --- a/src/__tests__/ammCalculator.test.ts +++ b/src/__tests__/ammCalculator.test.ts @@ -78,3 +78,195 @@ describe("constant-product invariant preservation", () => { expect(pool.reserves[1]!.amount).toBe(reserveOut.toString()); }); }); + +// --------------------------------------------------------------------------- +// SDK regression suite (issue #1195) +// +// A self-contained regression harness: deterministic cases are recorded as +// baselines, current results are compared against them, and a report with +// trend analysis is produced. Baselines are stored in-memory here so the +// suite is hermetic and CI-friendly; the same shape can be persisted to disk. +// --------------------------------------------------------------------------- + +interface RegressionCase { + name: string; + run: () => string; +} + +interface BaselineEntry { + output: string; + recordedAt: number; +} + +interface ComparisonResult { + name: string; + baseline: string; + current: string; + match: boolean; +} + +interface RegressionReport { + total: number; + passed: number; + failed: number; + comparisons: ComparisonResult[]; + trend: "stable" | "improving" | "regressing"; +} + +class BaselineStore { + private entries = new Map(); + + record(name: string, output: string, recordedAt = Date.now()): void { + this.entries.set(name, { output, recordedAt }); + } + + get(name: string): BaselineEntry | undefined { + return this.entries.get(name); + } + + has(name: string): boolean { + return this.entries.has(name); + } + + size(): number { + return this.entries.size; + } +} + +function compareAgainstBaseline( + store: BaselineStore, + cases: RegressionCase[], +): ComparisonResult[] { + return cases.map((testCase) => { + const current = testCase.run(); + const baseline = store.get(testCase.name); + return { + name: testCase.name, + baseline: baseline ? baseline.output : "", + current, + match: baseline !== undefined && baseline.output === current, + }; + }); +} + +function buildReport( + comparisons: ComparisonResult[], + previousFailures = 0, +): RegressionReport { + const failed = comparisons.filter((c) => !c.match).length; + const passed = comparisons.length - failed; + + let trend: RegressionReport["trend"] = "stable"; + if (failed < previousFailures) trend = "improving"; + else if (failed > previousFailures) trend = "regressing"; + + return { total: comparisons.length, passed, failed, comparisons, trend }; +} + +function formatReport(report: RegressionReport): string { + const lines = [ + `Regression report: ${report.passed}/${report.total} passed, ${report.failed} failed (trend: ${report.trend})`, + ]; + for (const c of report.comparisons) { + lines.push( + ` [${c.match ? "PASS" : "FAIL"}] ${c.name}: baseline=${c.baseline} current=${c.current}`, + ); + } + return lines.join("\n"); +} + +const regressionCases: RegressionCase[] = [ + { + name: "swap output for 10M into 1T/500B pool", + run: () => + estimateSwapOutput( + makePool("1000000000000", "500000000000"), + "10000000", + "XLM", + ).outputAmount, + }, + { + name: "swap output for 1M into 250B/250B pool", + run: () => + estimateSwapOutput( + makePool("250000000000", "250000000000"), + "1000000", + "XLM", + ).outputAmount, + }, + { + name: "zero-amount swap returns 0", + run: () => + estimateSwapOutput(makePool("1000000", "2000000"), "0", "XLM") + .outputAmount, + }, +]; + +describe("SDK regression suite", () => { + it("records baselines for every regression case", () => { + const store = new BaselineStore(); + for (const testCase of regressionCases) { + store.record(testCase.name, testCase.run()); + } + expect(store.size()).toBe(regressionCases.length); + for (const testCase of regressionCases) { + expect(store.has(testCase.name)).toBe(true); + } + }); + + it("matches current results against recorded baselines", () => { + const store = new BaselineStore(); + for (const testCase of regressionCases) { + store.record(testCase.name, testCase.run()); + } + + const comparisons = compareAgainstBaseline(store, regressionCases); + expect(comparisons).toHaveLength(regressionCases.length); + expect(comparisons.every((c) => c.match)).toBe(true); + }); + + it("detects a regression when output drifts from the baseline", () => { + const store = new BaselineStore(); + store.record(regressionCases[0]!.name, "999"); + + const comparisons = compareAgainstBaseline(store, [regressionCases[0]!]); + expect(comparisons[0]!.match).toBe(false); + + const report = buildReport(comparisons); + expect(report.failed).toBe(1); + expect(report.passed).toBe(0); + }); + + it("flags missing baselines as failures", () => { + const store = new BaselineStore(); + const comparisons = compareAgainstBaseline(store, [regressionCases[0]!]); + expect(comparisons[0]!.match).toBe(false); + expect(comparisons[0]!.baseline).toBe(""); + }); + + it("produces a clear report and identifies trends", () => { + const store = new BaselineStore(); + for (const testCase of regressionCases) { + store.record(testCase.name, testCase.run()); + } + const comparisons = compareAgainstBaseline(store, regressionCases); + + const stable = buildReport(comparisons, 0); + expect(stable.trend).toBe("stable"); + expect(stable.passed).toBe(regressionCases.length); + + const improving = buildReport(comparisons, 2); + expect(improving.trend).toBe("improving"); + + const regressing = buildReport( + [{ name: "x", baseline: "a", current: "b", match: false }], + 0, + ); + expect(regressing.trend).toBe("regressing"); + + const text = formatReport(stable); + expect(text).toContain("Regression report"); + expect(text).toContain("trend: stable"); + expect(text).toContain("[PASS]"); + }); +}); diff --git a/src/__tests__/sdkFeatures.test.ts b/src/__tests__/sdkFeatures.test.ts index 79dd324..9d55d29 100644 --- a/src/__tests__/sdkFeatures.test.ts +++ b/src/__tests__/sdkFeatures.test.ts @@ -23,10 +23,30 @@ describe("SDK Features (#854, #855, #856, #857)", () => { }; describe("Issue #855 - batchCreateInvoices", () => { - it("should validate batch size limit (1-20)", () => { + it("should validate batch size limit (1-20)", async () => { const client = new StellarSplitClient(config); - expect(() => client.batchCreateInvoices([])).rejects.toThrow(BatchTooLargeError); - expect(() => client.batchCreateInvoices(Array(21).fill({}))).rejects.toThrow(BatchTooLargeError); + await expect(client.batchCreateInvoices([])).rejects.toThrow(BatchTooLargeError); + await expect(client.batchCreateInvoices(Array(21).fill({}))).rejects.toThrow(BatchTooLargeError); + }); + + it("should accept a batch at the lower boundary (1)", async () => { + const client = new StellarSplitClient(config); + const invoices: CreateInvoiceParams[] = [ + { creator: "test", token: "test", deadline: 1000, recipients: [] }, + ]; + // Boundary of 1 must not be rejected by the size guard. + await expect(client.batchCreateInvoices(invoices)).rejects.not.toThrow(BatchTooLargeError); + }); + + it("should accept a batch at the upper boundary (20)", async () => { + const client = new StellarSplitClient(config); + const invoices: CreateInvoiceParams[] = Array.from({ length: 20 }, () => ({ + creator: "test", + token: "test", + deadline: 1000, + recipients: [], + })); + await expect(client.batchCreateInvoices(invoices)).rejects.not.toThrow(BatchTooLargeError); }); it("should validate all invoices before RPC call", async () => { @@ -36,7 +56,7 @@ describe("SDK Features (#854, #855, #856, #857)", () => { { creator: "", token: "test", deadline: 1000, recipients: [] }, ]; - expect(() => client.batchCreateInvoices(invoices)).rejects.toThrow(); + await expect(client.batchCreateInvoices(invoices)).rejects.toThrow(); }); }); @@ -76,6 +96,18 @@ describe("SDK Features (#854, #855, #856, #857)", () => { const result = await promise; expect(result).toEqual({ value: 42 }); }); + + it("should only fire once subscriptions a single time", () => { + const emitter = new TypedEventEmitter<{ test: { value: number } }>(); + const handler = vi.fn(); + emitter.once("test", handler); + + emitter.emit("test", { value: 1 }); + emitter.emit("test", { value: 2 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ value: 1 }); + }); }); describe("Issue #857 - Plugin System", () => { @@ -133,6 +165,14 @@ describe("SDK Features (#854, #855, #856, #857)", () => { expect(plugin.getMetrics()).toEqual({ createInvoice: 2 }); }); + it("should track distinct methods independently", () => { + const plugin = new MetricsPlugin(); + plugin.afterCall("createInvoice", { invoiceId: "test", txHash: "hash" }); + plugin.afterCall("pay", { txHash: "hash" }); + + expect(plugin.getMetrics()).toEqual({ createInvoice: 1, pay: 1 }); + }); + it("should reset metrics", () => { const plugin = new MetricsPlugin(); plugin.afterCall("pay", { txHash: "hash" });