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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ jobs:
- name: Verify dist
run: node -e "require('./dist/cjs/index.js')"

e2e-browser:
name: Browser compatibility (E2E)
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci
- run: npx playwright install --with-deps chromium firefox webkit
- run: npm run test:e2e

react-package:
name: "@streamfi/react"
runs-on: ubuntu-latest
Expand Down
671 changes: 374 additions & 297 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"dev": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:cli": "npm --prefix create-streamfi-app test",
"test:e2e": "playwright test --config tests/browser/playwright.config.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts"
Expand All @@ -42,6 +43,7 @@
"@stellar/stellar-sdk": "^12.0.0"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^11.1.6",
Expand All @@ -51,6 +53,7 @@
"@typescript-eslint/parser": "^7.14.0",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^8.57.0",
"playwright": "^1.62.0",
"rollup": "^4.18.0",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.5.3",
Expand Down
24 changes: 24 additions & 0 deletions tests/browser/compatibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';

test.describe('SDK browser compatibility', () => {
test('loads and passes all tests in Chromium', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.summary', { timeout: 60000 });
const summary = await page.textContent('.summary');
expect(summary).toContain('ALL');
});

test('loads and passes all tests in Firefox', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.summary', { timeout: 60000 });
const summary = await page.textContent('.summary');
expect(summary).toContain('ALL');
});

test('loads and passes all tests in WebKit', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.summary', { timeout: 60000 });
const summary = await page.textContent('.summary');
expect(summary).toContain('ALL');
});
});
139 changes: 139 additions & 0 deletions tests/browser/harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Conduit SDK Browser Compatibility Test</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { border-bottom: 2px solid #ddd; padding-bottom: 0.5rem; }
#results { margin-top: 1rem; }
.pass { color: #2e7d32; background: #e8f5e9; padding: 0.25rem 0.5rem; border-radius: 4px; margin: 0.25rem 0; }
.fail { color: #d32f2f; background: #fdecea; padding: 0.25rem 0.5rem; border-radius: 4px; margin: 0.25rem 0; }
.summary { margin-top: 1rem; font-weight: bold; }
pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<h1>Conduit SDK — Browser Compatibility Test</h1>
<p>Testing SDK utility functions and module loading across browsers.</p>
<pre id="log">Running tests...</pre>
<div id="results"></div>

<script type="importmap">
{
"imports": {
"@stellar/stellar-sdk": "./tests/browser/stellar-shim.js"
}
}
</script>

<script type="module">
const log = document.getElementById('log');
const results = document.getElementById('results');

let passed = 0;
let failed = 0;

function report(name, ok, detail) {
const el = document.createElement('div');
el.className = ok ? 'pass' : 'fail';
el.textContent = `${ok ? '✓' : '✗'} ${name}${detail !== undefined ? ` — ${detail}` : ''}`;
results.appendChild(el);
if (ok) passed++; else failed++;
return ok;
}

async function run() {
try {
// Test 1: Dynamically import the SDK ESM bundle
const sdk = await import('../../dist/esm/index.js');
report('SDK ESM bundle loads', true, `exports: ${Object.keys(sdk).length}`);

// Test 2: ConduitClient is exported and constructable
const { ConduitClient } = sdk;
report('ConduitClient exported', typeof ConduitClient === 'function', `typeof: ${typeof ConduitClient}`);

const client = new ConduitClient({
network: 'testnet',
factoryAddress: 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526',
governorAddress: 'CB3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
});
report('ConduitClient instantiates', true, `streams: ${typeof client.streams}`);

// Test 3: Client sub-modules exist
report('client.streams present', typeof client.streams === 'object', '');
report('client.factory present', typeof client.factory === 'object', '');
report('client.governor present', typeof client.governor === 'object', '');

// Test 4: StreamBuilder fluent API
const { StreamBuilder } = sdk;
report('StreamBuilder exported', typeof StreamBuilder === 'function', '');
new StreamBuilder()
.token('CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526')
.sender('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')
.recipient('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBHHF')
.amount(1000);
report('StreamBuilder chain', true, 'token/sender/recipient/amount');

// Test 5: Utils subpath import
const utils = await import('../../dist/esm/utils.js');
report('Utils subpath import', true, `exports: ${Object.keys(utils).length}`);

// Test 6: toStroops / fromStroops round-trip
const { toStroops, fromStroops } = utils;
const s = toStroops('100.5');
report('toStroops', s === 1005000000n, `100.5 → ${s}`);
report('fromStroops round-trip', fromStroops(s) === '100.5', '');

// Test 7: calculateRate
const { calculateRate } = utils;
const rate = calculateRate('1000', 86400);
report('calculateRate', rate > 0n, `1000/1day → ${rate} stroops/s`);

// Test 8: streamProgress
const { streamProgress } = utils;
const now = Math.floor(Date.now() / 1000);
const mock = {
startTime: now - 3600, endTime: now + 3600,
ratePerSecond: 1000n, withdrawn: 0n,
cancelled: false, paused: false, pausedAt: 0,
};
const p = streamProgress(mock, now);
report('streamProgress 50%', Math.abs(p - 0.5) < 0.01, `${p}`);

// Test 9: BigInt support
report('BigInt literal', 9223372036854775807n === 9223372036854775807n, '');

// Test 10: Error classes
const { ConduitError, StreamErrorCode, FactoryErrorCode } = sdk;
report('ConduitError exported', typeof ConduitError === 'function', '');
report('StreamErrorCode exported', !!StreamErrorCode, '');
report('FactoryErrorCode exported', !!FactoryErrorCode, '');

// Test 11: FeeEstimator
const { FeeEstimator } = sdk;
report('FeeEstimator exported', typeof FeeEstimator === 'function', '');

// Test 12: isValidAddress
const { isValidAddress } = utils;
report('isValidAddress valid', isValidAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'), '');
report('isValidAddress invalid', !isValidAddress('not-an-address'), '');

} catch (err) {
report('Test suite error', false, err.message);
log.textContent += `\nFATAL: ${err.stack}`;
}

const total = passed + failed;
const ok = passed === total;
const summary = document.createElement('div');
summary.className = 'summary';
summary.textContent = `${ok ? '✓ ALL' : '✗ SOME FAILED'} — ${passed}/${total} passed`;
results.appendChild(summary);
}

run();
</script>
</body>
</html>
27 changes: 27 additions & 0 deletions tests/browser/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test';
import path from 'path';

const ROOT = path.resolve(__dirname, '../..');

export default defineConfig({
testDir: '.',
testMatch: '*.spec.ts',
fullyParallel: true,
retries: 1,
workers: 3,
use: {
baseURL: 'http://localhost:9876',
headless: true,
},
webServer: {
command: `node "${path.resolve(__dirname, 'server.cjs')}"`,
port: 9876,
reuseExistingServer: true,
timeout: 10000,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
38 changes: 38 additions & 0 deletions tests/browser/server.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const http = require('http');
const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '../..');
const PORT = 9876;

const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.map': 'application/json',
};

const server = http.createServer((req, res) => {
const urlPath = req.url === '/' ? '/tests/browser/harness.html' : req.url;
let fp = path.join(ROOT, urlPath);
if (!fp.startsWith(ROOT)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const ext = path.extname(fp);
fs.readFile(fp, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(data);
});
});

server.listen(PORT, () => {
console.log(`Test server ready on http://localhost:${PORT}`);
});
94 changes: 94 additions & 0 deletions tests/browser/stellar-shim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Minimal shim of @stellar/stellar-sdk for browser compatibility testing.
// Only provides the exports that the Conduit SDK actually imports.

class StrKey {
static isValidEd25519PublicKey(addr) {
return typeof addr === 'string' && addr.length === 56 && addr.startsWith('G');
}
static isValidContract(addr) {
return typeof addr === 'string' && addr.length === 56 && addr.startsWith('C');
}
}

class Keypair {
static fromSecret(secret) {
return { publicKey: () => 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' };
}
}

class Account {
constructor(accountId, sequence) {
this.accountId = () => accountId;
this._sequence = sequence;
}
sequenceNumber() {
return this._sequence;
}
incrementSequenceNumber() {
this._sequence = (BigInt(this._sequence) + 1n).toString();
}
}

class Address {
constructor(addr) { this.addr = addr; }
toScVal() { return {}; }
static fromScVal() { return { toString: () => '' }; }
}

const SorobanRpc = {
Server: class {
constructor() {}
simulateTransaction() { return {}; }
sendTransaction() { return {}; }
getTransaction() { return {}; }
},
Api: {
isSimulationError() { return false; },
GetTransactionStatus: { SUCCESS: 'SUCCESS', FAILED: 'FAILED' },
},
assembleTransaction() { return { build: () => ({ sign: () => {}, toXDR: () => '' }) }; },
};

function nativeToScVal() { return {}; }
function boolToScVal() { return {}; }

const xdr = {
ScVal: { fromXDR: () => ({ map: () => [], sym: () => null, str: () => null, b: () => false }) },
};

const Networks = { PUBLIC: 'public', TESTNET: 'testnet' };

class Contract {
constructor(address) { this.address = address; }
}

class TransactionBuilder {
static fromXDR() { return {}; }
}

const BASE_FEE = '100';

class Transaction {
constructor(xdr, passphrase) {
this.xdr = xdr;
this.passphrase = passphrase;
}
sign() {}
toXDR() { return ''; }
}

export {
StrKey,
Keypair,
Account,
Address,
SorobanRpc,
nativeToScVal,
boolToScVal,
xdr,
Transaction,
Networks,
Contract,
TransactionBuilder,
BASE_FEE,
};
Loading