Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 24 additions & 44 deletions .github/workflows/conflict-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,33 @@ name: Conflict Check

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
branches: [main, master, develop]
push:
branches: [main, master, develop]

jobs:
check-conflicts:
conflict-check:
name: Check for merge conflicts
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check for merge conflicts
uses: actions/github-script@v7
- name: Checkout repository
uses: actions/checkout@v4
with:
script: |
let mergeable = null;
let attempts = 0;
fetch-depth: 0

// Poll until GitHub computes mergeability (can take a few seconds)
while (mergeable === null && attempts < 10) {
await new Promise(r => setTimeout(r, 3000));
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
mergeable = pr.mergeable;
attempts++;
}

if (mergeable === false) {
// Avoid posting duplicate comments
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const alreadyCommented = comments.some(c =>
c.body.includes('Merge Conflict Detected') &&
c.user.login === 'github-actions[bot]'
);

if (!alreadyCommented) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `⚠️ **Merge Conflict Detected**\n\nThis PR has conflicts with \`main\` that must be resolved before it can be merged.\n\n**Steps to fix:**\n\`\`\`bash\ngit checkout main\ngit pull origin main\ngit checkout your-branch\ngit merge main\n# resolve conflicts in your editor\ngit add .\ngit commit\ngit push\n\`\`\`\n\nIf you need help resolving the conflict, leave a comment and the maintainer will assist.\n\n---\n*🤖 Automated conflict check*`
});
}
}
- name: Detect merge conflicts
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="origin/${{ github.base_ref }}"
HEAD="${{ github.event.pull_request.head.sha }}"
else
BASE="origin/${{ github.ref_name }}"
HEAD="${{ github.sha }}"
fi
git fetch origin "${BASE#origin/}" --depth=1 || true
if git merge-tree "$(git merge-base "$BASE" "$HEAD")" "$BASE" "$HEAD" | grep -q '<<<<<<<'; then
echo "::error::Merge conflicts detected against $BASE"
exit 1
fi
echo "No merge conflicts detected."
41 changes: 29 additions & 12 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Integration Test
name: Integration Tests

on:
schedule:
Expand All @@ -7,26 +7,43 @@ on:

jobs:
integration-test:
name: Integration Tests
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 30

steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4

- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run integration tests (testnet only)
env:
STELLAR_NETWORK: testnet
STELLAR_SPLIT_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_CONTRACT_ID }}
STELLAR_SPLIT_TOKEN_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_TOKEN_CONTRACT_ID }}
- name: Run integration tests
run: npm run test:integration
env:
CI: true

- name: Run unit tests with coverage
run: npm run test:coverage
env:
CI: true

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage/**
coverage/lcov.info
if-no-files-found: ignore
retention-days: 14

fuzz:
runs-on: ubuntu-latest
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,20 @@ jobs:
- name: Type check (strict)
run: npx --no-install tsc --noEmit

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: sdk-dist
path: dist
if-no-files-found: error
retention-days: 30

- name: Publish
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Notify on failure
if: failure()
run: |
echo "::error::Publish pipeline failed for ${{ github.ref_name }}. See ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
57 changes: 57 additions & 0 deletions load-tests/baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"version": 1,
"description": "Performance baselines for SDK load tests. Update via `npm run load-test -- --update-baseline` after intentional performance changes.",
"updatedAt": "2024-01-01T00:00:00.000Z",
"scenarios": {
"invoice-split": {
"description": "Split a single invoice across multiple recipients",
"concurrency": 10,
"durationMs": 30000,
"baseline": {
"throughputOpsPerSec": 250,
"latencyMs": {
"p50": 35,
"p90": 60,
"p95": 80,
"p99": 120
},
"errorRate": 0.001
}
},
"batch-payments": {
"description": "Submit batches of payments through the SDK",
"concurrency": 20,
"durationMs": 30000,
"baseline": {
"throughputOpsPerSec": 120,
"latencyMs": {
"p50": 90,
"p90": 150,
"p95": 200,
"p99": 320
},
"errorRate": 0.002
}
},
"webhook-handling": {
"description": "Process inbound webhook events",
"concurrency": 50,
"durationMs": 30000,
"baseline": {
"throughputOpsPerSec": 800,
"latencyMs": {
"p50": 10,
"p90": 20,
"p95": 30,
"p99": 55
},
"errorRate": 0.0005
}
}
},
"regressionThresholds": {
"throughputDropPct": 15,
"latencyIncreasePct": 20,
"errorRateIncreasePct": 50
}
}
175 changes: 175 additions & 0 deletions load-tests/generators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Load generators for the load testing and benchmarking suite (#1190).
*
* Drives configurable concurrency/duration against representative SDK
* scenarios and collects per-request latency samples so the reporting and
* baseline layers can compute throughput, percentiles and error rates.
*/

export interface LoadScenario {
/** Stable identifier used in reports and baselines. */
name: string;
/** Human readable description of what the scenario exercises. */
description: string;
/** A single unit of work executed by the generator. */
run: () => Promise<unknown>;
}

export interface LoadGeneratorOptions {
/** Number of concurrent workers. Defaults to 1. */
concurrency?: number;
/** Total wall-clock duration in milliseconds. Defaults to 1000. */
durationMs?: number;
/** Optional cap on total iterations across all workers. */
maxIterations?: number;
/** Optional delay between iterations per worker, in milliseconds. */
thinkTimeMs?: number;
}

export interface LoadSample {
/** Latency of the iteration in milliseconds. */
latencyMs: number;
/** Whether the iteration completed without throwing. */
success: boolean;
/** Error message when the iteration failed. */
error?: string;
}

export interface LoadResult {
scenario: string;
concurrency: number;
durationMs: number;
/** Total iterations attempted (successful + failed). */
iterations: number;
/** Iterations that completed without throwing. */
successes: number;
/** Iterations that threw. */
failures: number;
/** Successful iterations per second over the measured window. */
throughput: number;
/** Fraction of failed iterations in [0, 1]. */
errorRate: number;
/** Latency percentiles in milliseconds (successful iterations only). */
latency: {
min: number;
mean: number;
p50: number;
p90: number;
p95: number;
p99: number;
max: number;
};
/** Raw samples for downstream trend analysis. */
samples: LoadSample[];
}

const DEFAULT_CONCURRENCY = 1;
const DEFAULT_DURATION_MS = 1000;

function sleep(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}

function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const rank = (p / 100) * (sorted.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return sorted[lower];
const weight = rank - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}

function summarizeLatency(samples: LoadSample[]): LoadResult['latency'] {
const latencies = samples
.filter((s) => s.success)
.map((s) => s.latencyMs)
.sort((a, b) => a - b);

if (latencies.length === 0) {
return { min: 0, mean: 0, p50: 0, p90: 0, p95: 0, p99: 0, max: 0 };
}

const sum = latencies.reduce((acc, v) => acc + v, 0);
return {
min: latencies[0],
mean: sum / latencies.length,
p50: percentile(latencies, 50),
p90: percentile(latencies, 90),
p95: percentile(latencies, 95),
p99: percentile(latencies, 99),
max: latencies[latencies.length - 1],
};
}

/**
* Run a scenario under a fixed concurrency for a bounded duration and
* collect latency/error samples for reporting and baseline comparison.
*/
export async function runLoadGenerator(
scenario: LoadScenario,
options: LoadGeneratorOptions = {},
): Promise<LoadResult> {
const concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY));
const durationMs = Math.max(0, options.durationMs ?? DEFAULT_DURATION_MS);
const maxIterations = options.maxIterations ?? Number.POSITIVE_INFINITY;
const thinkTimeMs = Math.max(0, options.thinkTimeMs ?? 0);

const samples: LoadSample[] = [];
const deadline = Date.now() + durationMs;
let iterations = 0;

const worker = async (): Promise<void> => {
while (Date.now() < deadline && iterations < maxIterations) {
iterations += 1;
const startedAt = Date.now();
try {
await scenario.run();
samples.push({ latencyMs: Date.now() - startedAt, success: true });
} catch (error) {
samples.push({
latencyMs: Date.now() - startedAt,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (thinkTimeMs > 0) await sleep(thinkTimeMs);
}
};

const startedAt = Date.now();
await Promise.all(Array.from({ length: concurrency }, () => worker()));
const elapsedMs = Math.max(1, Date.now() - startedAt);

const successes = samples.filter((s) => s.success).length;
const failures = samples.length - successes;

return {
scenario: scenario.name,
concurrency,
durationMs: elapsedMs,
iterations: samples.length,
successes,
failures,
throughput: (successes / elapsedMs) * 1000,
errorRate: samples.length === 0 ? 0 : failures / samples.length,
latency: summarizeLatency(samples),
samples,
};
}

/**
* Run multiple scenarios sequentially and return their results keyed by name.
*/
export async function runLoadSuite(
scenarios: LoadScenario[],
options: LoadGeneratorOptions = {},
): Promise<LoadResult[]> {
const results: LoadResult[] = [];
for (const scenario of scenarios) {
results.push(await runLoadGenerator(scenario, options));
}
return results;
}
Loading
Loading