Skip to content
Open
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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,53 @@ jobs:
run: npm run build
working-directory: frontend

e2e:
name: E2E Tests
needs: frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: package-lock.json

- name: Install dependencies
run: npm ci

- name: Cache Playwright binaries
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}

- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps

- name: Build Frontend
run: npm run build
working-directory: frontend

- name: Run Playwright tests
run: npx playwright test
working-directory: frontend
env:
NEXT_PUBLIC_API_URL: http://localhost:3000

- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30

backend:
name: Backend CI
runs-on: ubuntu-latest
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ coverage
# Soroban test runner — auto-generated, never commit
**/test_snapshots/
fix.md
.npm-cache/
.npm-cache/

# Playwright test execution items
frontend/playwright-report/
frontend/test-results/
12 changes: 5 additions & 7 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,13 @@ export function verifyJwt(token: string): { publicKey: string } | null {
.update(`${header}.${body}`)
.digest();

let providedSig: Buffer;
try {
providedSig = Buffer.from(sig, 'base64url');
} catch {
const expectedSigB64 = b64url(expected);

// Use timingSafeEqual on the base64url strings to prevent timing attacks and signature malleability
if (sig.length !== expectedSigB64.length) {
return null;
}

// Use timingSafeEqual to prevent timing attacks
if (providedSig.length !== expected.length || !crypto.timingSafeEqual(providedSig, expected)) {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSigB64))) {
return null;
}

Expand Down
251 changes: 251 additions & 0 deletions frontend/e2e/stream-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { test, expect, type Page, type Route } from "@playwright/test";

test.describe("Stream Lifecycle Flow", () => {
const MOCK_ADDRESS =
"GCKSZH3YZR7BBA76LXI4RUKMVMSTJ67DIABZTQA2H3ISG5VZGLNU2NGP";

test.beforeEach(async ({ page }: { page: Page }) => {
// Mock Freighter extension messaging
await page.addInitScript((addr: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).E2E_MOCK_SOROBAN = true;

// The freighter API sets up a listener for FREIGHTER_EXTERNAL_MSG_RESPONSE
window.addEventListener("message", (event) => {
if (
event.data &&
event.data.source === "FREIGHTER_EXTERNAL_MSG_REQUEST"
) {
const { type, messageId } = event.data;

let responseData = {};
if (type === "REQUEST_CONNECTION_STATUS") {
responseData = { isConnected: true };
} else if (type === "REQUEST_PUBLIC_KEY") {
responseData = { publicKey: addr };
} else if (type === "REQUEST_NETWORK_DETAILS") {
responseData = {
networkDetails: {
network: "TESTNET",
networkUrl: "https://horizon-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
},
};
} else if (type === "SET_ALLOWED_STATUS") {
responseData = { isAllowed: true };
} else if (type === "REQUEST_ALLOWED_STATUS") {
responseData = { isAllowed: true };
} else if (type === "SIGN_TRANSACTION") {
responseData = {
signedTransaction: "mock_signed_tx_xdr",
signedTxXdr: "mock_signed_tx_xdr",
signerAddress: addr,
};
} else if (type === "REQUEST_ACCESS") {
responseData = { publicKey: addr };
}

window.postMessage(
{
source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",
messagedId: messageId,
...responseData,
},
"*",
);
}
});

// Also set window.freighter just in case
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).freighter = true;
}, MOCK_ADDRESS);

// Setup API interception routes
// Note: use route.fulfill with JSON so the stream list can render deterministically.

await page.route("**/v1/streams/*/events*", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
}),
});
});

await page.route("**/v1/users/*/summary", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
address: MOCK_ADDRESS,
totalStreamsCreated: 1,
totalStreamedOut: "100000000",
totalStreamedIn: "0",
currentClaimable: "0",
activeOutgoingCount: 1,
activeIncomingCount: 0,
}),
});
});

await page.route("**/v1/streams*", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
streamId: 101,
sender: MOCK_ADDRESS,
recipient: "G...RECEIVER",
tokenAddress: "C...TOKEN",
ratePerSecond: "100",
depositedAmount: "1000000",
withdrawnAmount: "0",
startTime: Math.floor(Date.now() / 1000),
lastUpdateTime: Math.floor(Date.now() / 1000),
isActive: true,
isPaused: false,
status: "Active",
},
]),
});
});

// Mock individual stream endpoint since we navigate to details
await page.route("**/v1/streams/101", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
streamId: 101,
sender: MOCK_ADDRESS,
recipient: "G...RECEIVER",
tokenAddress: "C...TOKEN",
ratePerSecond: "100",
depositedAmount: "1000000",
withdrawnAmount: "0",
startTime: Math.floor(Date.now() / 1000),
lastUpdateTime: Math.floor(Date.now() / 1000),
isActive: true,
isPaused: false,
status: "Active",
}),
});
});
});

test("should pause, resume, and cancel a stream", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(60000); // 60 seconds

await page.goto("http://localhost:3000/dashboard");

// On the dashboard without a wallet, the connect modal appears automatically.
const connectModal = page.getByRole("dialog", {
name: /connect a wallet/i,
});
await expect(connectModal).toBeVisible({ timeout: 7000 });

// Select Freighter from the modal
const freighterBtn = page.getByRole("button", {
name: /connect freighter/i,
});
await freighterBtn.waitFor({ state: "visible", timeout: 5000 });
await freighterBtn.click();

// Wait for modal to close indicating successful connection
await expect(connectModal).toBeHidden({ timeout: 7000 });

// Wait for the mocked stream list to mount
await expect(
page.getByRole("link", { name: /details/i }).first(),
).toBeVisible({ timeout: 30000 });

// --- Create a new stream ---
await page.getByRole("link", { name: "Create Stream" }).click();
await expect(
page.getByRole("heading", { name: "Create New Stream" }),
).toBeVisible({ timeout: 30000 });

await page
.getByPlaceholder("G...")
.fill("GDOS6EZLJWBJIX7FUIC5EJ657T6MAFCADO524ZHFCKUIE3VZX23JRCY5");
await page.getByPlaceholder("0.00").fill("100");
await page.getByRole("button", { name: "Start Streaming" }).click();

// Verify success and redirect
await expect(page.getByText("Stream created successfully!")).toBeVisible({
timeout: 30000,
});
// Wait for the automatic redirect back to dashboard
await expect(page.getByRole("heading", { name: /Streams/i })).toBeVisible({
timeout: 10000,
});

// --- Pause and Resume the stream ---
// Navigate directly to the details page to avoid click bubbling issues on the row
await page.goto("http://localhost:3000/streams/101");

await expect(
page.getByRole("heading", { name: /Stream Details/i }),
).toBeVisible({ timeout: 30000 });

// Mock update BEFORE clicking pause! This prevents a race condition where
// fetchStream() is called before the test runner sets up the new route.
await page.route("**/v1/streams/101", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
streamId: 101,
sender: "GCKSZH3YZR7BBA76LXI4RUKMVMSTJ67DIABZTQA2H3ISG5VZGLNU2NGP",
recipient: "G...RECEIVER",
tokenAddress: "C...TOKEN",
ratePerSecond: "100",
depositedAmount: "1000000",
withdrawnAmount: "0",
startTime: Math.floor(Date.now() / 1000),
lastUpdateTime: Math.floor(Date.now() / 1000),
isActive: true,
isPaused: true,
status: "Paused",
}),
});
});

// Click Pause
await page.getByRole("button", { name: "Pause" }).click();
await expect(page.getByText("Stream paused")).toBeVisible({
timeout: 30000,
});

// Wait for the UI to reflect the paused state
await expect(page.getByRole("button", { name: "Resume" })).toBeVisible({
timeout: 10000,
});

// Click Resume
await page.getByRole("button", { name: "Resume" }).click();
await expect(page.getByText("Stream resumed")).toBeVisible({
timeout: 30000,
});

// --- Cancel Stream ---
// The details page has a 'Cancel' button with an X icon
await page.getByRole("button", { name: "Cancel", exact: true }).click();

// This opens the CancelConfirmModal
await page.getByRole("button", { name: "Yes, Cancel Stream" }).click();

// Status should be verified by the success toast
await expect(page.getByText("Stream cancelled")).toBeVisible({
timeout: 30000,
});
});
});
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@tailwindcss/postcss": "^4.3.1",
"@playwright/test": "^1.59.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
Expand Down
34 changes: 34 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",

trace: "on-first-retry",
screenshot: "only-on-failure",
},

projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],

webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
},
});
2 changes: 1 addition & 1 deletion frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ function renderStreams(
<div className="flex items-center justify-end gap-2">
{/^\d+$/.test(stream.id) ? (
<Link
href={`/app/streams/${stream.id}`}
href={`/streams/${stream.id}`}
className="secondary-button py-1 px-3 text-sm h-auto inline-flex items-center"
>
Details
Expand Down
Loading
Loading