diff --git a/frontend/.gitignore b/frontend/.gitignore index 49a317f1..66ba020b 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -44,3 +44,8 @@ next-env.d.ts # manually-triggered API type codegen output (see src/lib/api-types.ts) src/lib/api-types.generated.ts + +# playwright e2e artifacts +test-results/ +playwright-report/ +e2e/.e2e-certs/ diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 00000000..c567d496 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const CERT_DIR = path.join(__dirname, ".e2e-certs"); +const CERT_PATH = path.join(CERT_DIR, "cert.pem"); +const KEY_PATH = path.join(CERT_DIR, "key.pem"); + +export default function globalSetup() { + if (fs.existsSync(CERT_PATH) && fs.existsSync(KEY_PATH)) { + return; + } + fs.mkdirSync(CERT_DIR, { recursive: true }); + execSync( + [ + "openssl req -x509 -newkey rsa:2048 -nodes", + `-keyout ${KEY_PATH}`, + `-out ${CERT_PATH}`, + "-days 3650", + '-subj "/CN=localhost"', + '-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"', + ].join(" "), + { stdio: "pipe" }, + ); +} \ No newline at end of file diff --git a/frontend/e2e/mocks/api-server.mjs b/frontend/e2e/mocks/api-server.mjs new file mode 100644 index 00000000..94a90774 --- /dev/null +++ b/frontend/e2e/mocks/api-server.mjs @@ -0,0 +1,394 @@ +import http from "node:http"; +import https from "node:https"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { xdr, Keypair, TransactionBuilder, Networks } from "@stellar/stellar-sdk"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CERT_DIR = path.join(__dirname, "..", ".e2e-certs"); +const CERT_PATH = path.join(CERT_DIR, "cert.pem"); +const KEY_PATH = path.join(CERT_DIR, "key.pem"); + +const PORT = Number(process.env.MOCK_API_PORT || 3100); +const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102); +const APP_ORIGIN = process.env.E2E_APP_ORIGIN || "http://localhost:3101"; +const SESSION_PUBLIC_KEY = "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U"; + +const RECIPIENT_PUBLIC_KEY = "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG"; +const USDC_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +const RATE_PER_SECOND = "10000000"; // 1 USDC / second (7 decimals) +const DEPOSITED_AMOUNT = "100000000000"; // 10,000 USDC +const WITHDRAW_BATCH = BigInt("100000000"); // 10 USDC per simulated withdrawal + +const accountSequence = ["1"]; + +const nowSec = () => Math.floor(Date.now() / 1000); + +function createStream() { + return { + id: "42", + streamId: 42, + sender: SESSION_PUBLIC_KEY, + recipient: RECIPIENT_PUBLIC_KEY, + tokenAddress: USDC_ADDRESS, + ratePerSecond: RATE_PER_SECOND, + depositedAmount: DEPOSITED_AMOUNT, + withdrawnAmount: "0", + startTime: nowSec() - 15, + lastUpdateTime: nowSec() - 3, + endTime: null, + isActive: true, + isPaused: false, + status: "active", + pausedAt: null, + totalPausedDuration: 0, + createdAt: new Date(Date.now() - 3600000).toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +let stream = createStream(); +const watchers = new Set(); + +function broadcast(eventName, data) { + const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of watchers) { + try { + res.write(payload); + } catch { + watchers.delete(res); + } + } +} + +function corsHeaders() { + return { + "Access-Control-Allow-Origin": APP_ORIGIN, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }; +} + +const sendJson = (res, statusCode, body, extraHeaders = {}) => { + const headers = { "Content-Type": "application/json", ...corsHeaders(), ...extraHeaders }; + res.writeHead(statusCode, headers); + res.end(JSON.stringify(body)); +}; + +const readBody = (req) => + new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); + +// ── XDR factories ──────────────────────────────────────────────────────────── + +function accountEntryXdr(publicKey, seq) { + const kp = Keypair.fromPublicKey(publicKey); + const accountEntry = new xdr.AccountEntry({ + accountId: kp.xdrAccountId(), + balance: xdr.Int64.fromString("0"), + seqNum: new xdr.SequenceNumber(xdr.Int64.fromString(String(seq))), + numSubEntries: 0, + flags: 0, + homeDomain: "", + thresholds: new Uint8Array(4), + signers: [], + ext: new xdr.AccountEntryExt(0), + }); + return xdr.LedgerEntryData.account(accountEntry); +} + +function ledgerKeyXdr(publicKey) { + const kp = Keypair.fromPublicKey(publicKey); + return xdr.LedgerKey.account(new xdr.LedgerKeyAccount({ accountId: kp.xdrPublicKey() })); +} + +function sorobanTransactionDataBase64() { + const footprint = new xdr.LedgerFootprint({ readOnly: [], readWrite: [] }); + const resources = new xdr.SorobanResources({ + footprint, + instructions: 0, + diskReadBytes: 8, + writeBytes: 8, + }); + const data = new xdr.SorobanTransactionData({ + resources, + resourceFee: 0n, + ext: new xdr.SorobanTransactionDataExt(0), + }); + return data.toXDR("base64"); +} + +function scValVoidBase64() { + return xdr.ScVal.scvVoid().toXDR("base64"); +} + +// ── REST / SSE handlers ────────────────────────────────────────────────────── + +function buildEvents() { + const events = [ + { + id: "1", + streamId: 42, + eventType: "CREATED", + timestamp: nowSec() - 3600, + amount: DEPOSITED_AMOUNT, + }, + ]; + if (Number(stream.withdrawnAmount) > 0) { + events.push({ + id: "2", + streamId: 42, + eventType: "WITHDRAWN", + timestamp: nowSec(), + amount: stream.withdrawnAmount, + }); + } + return events; +} + +function handleRest(req, res) { + if (req.method === "OPTIONS") { + res.writeHead(204, corsHeaders()); + return res.end(); + } + + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === "GET" && url.pathname === "/health") { + return sendJson(res, 200, { ok: true }); + } + + if ( + req.method === "GET" && + (url.pathname === "/v1/streams" || url.pathname === "/api/v1/streams") + ) { + return sendJson(res, 200, { data: [stream] }); + } + + const streamDetail = url.pathname.match(/^\/v1\/streams\/(\d+)$/); + if (req.method === "GET" && streamDetail) { + return sendJson(res, 200, stream); + } + + const streamEvents = url.pathname.match(/^\/v1\/streams\/(\d+)\/events$/); + if (req.method === "GET" && streamEvents) { + const all = buildEvents(); + return sendJson(res, 200, { + events: all, + total: all.length, + page: Number(url.searchParams.get("page") || 1), + limit: Number(url.searchParams.get("limit") || 20), + }); + } + + if (req.method === "GET" && url.pathname === "/v1/events/subscribe") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": APP_ORIGIN, + }); + res.write(`retry: 3000\n\n`); + watchers.add(res); + req.on("close", () => watchers.delete(res)); + const heartbeat = setInterval(() => { + try { + res.write(": ping\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 15000); + res.on("close", () => clearInterval(heartbeat)); + return; + } + + const withdrawControl = url.pathname.match(/^\/__e2e\/stream\/(\d+)\/withdraw$/); + if (req.method === "POST" && withdrawControl) { + const current = Number(stream.withdrawnAmount) || 0; + stream = { + ...stream, + withdrawnAmount: (current + Number(WITHDRAW_BATCH)).toString(), + updatedAt: new Date().toISOString(), + lastUpdateTime: nowSec(), + }; + broadcast("stream.withdrawn", { streamId: 42 }); + return sendJson(res, 200, { ok: true, withdrawnAmount: stream.withdrawnAmount }); + } + + return sendJson(res, 404, { error: "not found" }); +} + +// ── Soroban RPC handlers ───────────────────────────────────────────────────── + +function rpcError(id, code, message) { + return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function rpcResult(id, result) { + return JSON.stringify({ jsonrpc: "2.0", id, result }); +} + +let submittedTransactionXdr = null; + +function successTxResultBase64() { + return new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString("0"), + result: xdr.TransactionResultResult.txSuccess([]), + ext: new xdr.TransactionResultExt(0), + }).toXDR("base64"); +} + +function zeroTxMetaBase64() { + return new xdr.TransactionMeta(0, []).toXDR("base64"); +} + +function dummyEnvelopeBase64() { + return new TransactionBuilder( + Keypair.fromPublicKey(SESSION_PUBLIC_KEY), + { fee: "1", networkPassphrase: Networks.TESTNET }, + ) + .setTimeout(30) + .build() + .toXDR("base64"); +} + +async function handleRpc(req, res) { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Access-Control-Allow-Origin", APP_ORIGIN); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + if (req.method !== "POST") { + res.writeHead(405); + return res.end(rpcError(null, -32600, "method not allowed")); + } + + let body; + try { + body = await readBody(req); + } catch { + res.writeHead(400); + return res.end(JSON.stringify({ error: "invalid json" })); + } + + const { id, method, params } = body; + + try { + switch (method) { + case "getHealth": + return res.end(rpcResult(id, { status: "healthy" })); + + case "getNetwork": + return res.end( + rpcResult(id, { + friendbotUrl: "https://friendbot-futurenet.stellar.org/", + passthroughUrls: {}, + sorobanRpcUrl: "", + }), + ); + + case "getLatestLedger": + return res.end( + rpcResult(id, { + id: "0000000000000000000000000000000000000000000000000000000000000000", + protocolVersion: 22, + sequence: 1000, + }), + ); + + case "getLedgerEntries": { + const keys = Array.isArray(params?.keys) ? params.keys : []; + const entries = keys.length + ? keys.map((keyBase64) => ({ + key: keyBase64, + xdr: accountEntryXdr(SESSION_PUBLIC_KEY, 1).toXDR("base64"), + lastModifiedLedgerSeq: 0, + })) + : []; + return res.end(rpcResult(id, { latestLedger: 1000, entries })); + } + + case "simulateTransaction": + return res.end( + rpcResult(id, { + id: "sim-1", + latestLedger: 1000, + transactionData: sorobanTransactionDataBase64(), + minResourceFee: "0", + cost: { cpuInsns: "0", memBytes: "0" }, + results: [{ auth: [], xdr: scValVoidBase64() }], + events: [], + }), + ); + + case "sendTransaction": + submittedTransactionXdr = params?.transaction ?? null; + return res.end( + rpcResult(id, { + status: "PENDING", + hash: "0000000000000000000000000000000000000000000000000000000000000000", + latestLedger: 1000, + latestLedgerCloseTime: 0, + }), + ); + + case "getTransaction": + return res.end( + rpcResult(id, { + status: "SUCCESS", + latestLedger: 1000, + latestLedgerCloseTime: 0, + ledger: 1000, + applicationOrder: 1, + feeBump: false, + envelopeXdr: + submittedTransactionXdr ?? dummyEnvelopeBase64(), + resultXdr: successTxResultBase64(), + resultMetaXdr: zeroTxMetaBase64(), + }), + ); + + default: + return res.end(rpcError(id, -32601, `method not found: ${method}`)); + } + } catch (err) { + res.end(rpcError(id, -32603, err.message)); + } +} + +// ── Bootstrap ──────────────────────────────────────────────────────────────── + +const server = http.createServer(handleRest); +server.listen(PORT, "0.0.0.0", () => { + console.log(`[mock-api] rest+sse listening on http://localhost:${PORT}`); +}); + +if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) { + console.error("[mock-api] missing TLS certs — run the pw global-setup first (playwright install)"); + process.exit(1); +} + +const rpcServer = https.createServer( + { cert: fs.readFileSync(CERT_PATH), key: fs.readFileSync(KEY_PATH) }, + handleRpc, +); +rpcServer.listen(RPC_PORT, "0.0.0.0", () => { + console.log(`[mock-api] soroban rpc listening on https://localhost:${RPC_PORT}`); +}); \ No newline at end of file diff --git a/frontend/e2e/stream-creation.spec.ts b/frontend/e2e/stream-creation.spec.ts new file mode 100644 index 00000000..4209b7e3 --- /dev/null +++ b/frontend/e2e/stream-creation.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter"; + +test("single-screen /streams/create form validates input and submits a stream", async ({ + page, +}) => { + await mockConnectedWallet(page); + await page.goto("/streams/create"); + + await expect(page.getByRole("heading", { name: "Create New Stream" })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator(".wallet-chip").first()).toBeVisible({ timeout: 30_000 }); + + const amount = page.locator("#create-stream-amount"); + + await amount.fill("0"); + await expect(page.getByText("Amount must be greater than 0")).toBeVisible(); + + await page.locator("#recipient").fill(RECIPIENT_PUBLIC_KEY); + await page.locator("#create-stream-token").selectOption("USDC"); + await amount.fill("10"); + await page.locator("#create-stream-duration").fill("7"); + + await expect(page.getByText("0.00001653 USDC/sec")).toBeVisible(); + + await page.getByRole("button", { name: "Start Streaming" }).click(); + + await expect(page.getByText("Stream created successfully!")).toBeVisible({ + timeout: 20_000, + }); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); +}); \ No newline at end of file diff --git a/frontend/e2e/stream-lifecycle.spec.ts b/frontend/e2e/stream-lifecycle.spec.ts new file mode 100644 index 00000000..495c5792 --- /dev/null +++ b/frontend/e2e/stream-lifecycle.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter"; + +const MOCK_API_URL = "http://localhost:3100"; + +test("stream detail page shows stream state and reflects a withdrawal via mock events", async ({ + page, +}) => { + // Connect as the stream RECIPIENT so the Withdraw action is available. + await mockConnectedWallet(page, RECIPIENT_PUBLIC_KEY); + + await page.goto("/streams/42"); + + const withdrawnCard = page.locator(".glass-card", { hasText: "Withdrawn" }).first(); + const claimableCard = page.locator(".glass-card", { hasText: "Claimable" }).first(); + + await expect(withdrawnCard).toContainText("0 USDC", { timeout: 30_000 }); + // Live claimable is capped at the deposited amount by the dashboard contract. + await expect(claimableCard).toContainText("10000 USDC", { timeout: 30_000 }); + + const bump = await page.request.post(`${MOCK_API_URL}/__e2e/stream/42/withdraw`); + expect(bump.ok()).toBeTruthy(); + + await expect(withdrawnCard).toContainText("10 USDC", { timeout: 20_000 }); + + const eventRow = page + .locator("div.flex.items-center.gap-4.py-3", { hasText: "Withdrawn" }) + .first(); + await expect(eventRow).toBeVisible({ timeout: 20_000 }); + + const withdrawnButton = page + .getByRole("button", { name: /Withdraw/, exact: false }) + .first(); + await expect(withdrawnButton).toBeEnabled(); + await withdrawnButton.click(); + + await expect(page.getByText("Withdrawal successful!")).toBeVisible({ + timeout: 30_000, + }); +}); \ No newline at end of file diff --git a/frontend/e2e/utils/freighter.ts b/frontend/e2e/utils/freighter.ts new file mode 100644 index 00000000..330278a6 --- /dev/null +++ b/frontend/e2e/utils/freighter.ts @@ -0,0 +1,121 @@ +import type { Page } from "@playwright/test"; + +export const WALLET_PUBLIC_KEY = + "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U"; +export const RECIPIENT_PUBLIC_KEY = + "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG"; +export const SESSION_STORAGE_KEY = "flowfi.wallet.session.v1"; + +/** + * Injects a window-level mock for the Freighter browser extension using the + * postMessage protocol implemented by @stellar/freighter-api v6 + * (FREIGHTER_EXTERNAL_MSG_REQUEST / FREIGHTER_EXTERNAL_MSG_RESPONSE). + */ +export function freighterInitScript(address: string): string { + return ` + (() => { + const address = ${JSON.stringify(address)}; + + window.freighter = { version: "mock" }; + + const respond = (messageId, payload) => { + window.postMessage( + { + source: "FREIGHTER_EXTERNAL_MSG_RESPONSE", + messagedId: messageId, + extensionName: "FREIGHTER", + apiVersion: 1, + ...payload, + }, + window.location.origin, + ); + }; + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + const data = event.data || {}; + if (data.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST") return; + + switch (data.type) { + case "REQUEST_ACCESS": + case "REQUEST_PUBLIC_KEY": + respond(data.messageId, { publicKey: address, error: undefined }); + break; + case "REQUEST_CONNECTION_STATUS": + respond(data.messageId, { isConnected: true }); + break; + case "REQUEST_ALLOWED_STATUS": + case "SET_ALLOWED_STATUS": + respond(data.messageId, { isAllowed: true }); + break; + case "REQUEST_NETWORK_DETAILS": + respond(data.messageId, { + networkDetails: { + network: "TESTNET", + networkName: "SDF Test Network", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + }, + error: undefined, + }); + break; + case "SUBMIT_TRANSACTION": + respond(data.messageId, { + signedTransaction: data.transactionXdr, + signerAddress: address, + error: undefined, + }); + break; + default: + respond(data.messageId, { + error: { code: -2, message: "Unsupported mock request: " + data.type }, + }); + break; + } + }); + })(); + `; +} + +export async function installFreighterMock( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await page.addInitScript(freighterInitScript(address)); +} + +/** + * Seeds a persisted, non-mocked wallet session so pages hydrate straight into + * the connected state without opening the connect modal. + */ +export async function seedWalletSession( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await page.addInitScript( + ({ key, storageKey }) => { + window.localStorage.setItem( + storageKey, + JSON.stringify({ + walletId: "freighter", + walletName: "Freighter", + publicKey: key, + connectedAt: new Date().toISOString(), + network: "Testnet", + mocked: false, + }), + ); + }, + { key: address, storageKey: SESSION_STORAGE_KEY }, + ); +} + +/** Sets up a mocked Freighter extension AND a persisted connected session. */ +export async function mockConnectedWallet( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await installFreighterMock(page, address); + await seedWalletSession(page, address); +} \ No newline at end of file diff --git a/frontend/e2e/wallet-connection.spec.ts b/frontend/e2e/wallet-connection.spec.ts new file mode 100644 index 00000000..27557db0 --- /dev/null +++ b/frontend/e2e/wallet-connection.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; +import { installFreighterMock, WALLET_PUBLIC_KEY } from "./utils/freighter"; + +test("connects a Freighter wallet, shows the account badge, and disconnects", async ({ + page, +}) => { + await installFreighterMock(page); + + await page.goto("/"); + + const connectButton = page.locator(".wallet-connect-btn").first(); + await expect(connectButton).toBeVisible({ timeout: 30_000 }); + await connectButton.click(); + + const dialog = page.getByRole("dialog", { name: "Connect a wallet" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Connect Freighter" }).click(); + + const chip = page.locator(".wallet-chip").first(); + await expect(chip).toBeVisible({ timeout: 15_000 }); + await expect(chip).toContainText(WALLET_PUBLIC_KEY.slice(0, 4)); + + await chip.click(); + await page.getByRole("menuitem", { name: "Disconnect" }).click(); + await expect(page.locator(".wallet-connect-btn").first()).toBeVisible(); +}); \ No newline at end of file diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 31c5ed1c..c90d14b9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,13 @@ +import path from "node:path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // The workspace root lives one level above this directory. Pinning it here + // prevents Turbopack from inferring a wrong root when stray package-lock + // files exist outside the repo (e.g. ~/package-lock.json). + turbopack: { + root: path.join(path.dirname(new URL(import.meta.url).pathname), ".."), + }, // Enable tree-shaking for icon/utility libraries to reduce per-route // bundle sizes (Issue #1254). experimental: { diff --git a/frontend/package.json b/frontend/package.json index 02bb9898..524b1063 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", "codegen:api-types": "openapi-typescript http://localhost:3001/api-docs.json -o src/lib/api-types.generated.ts" }, "dependencies": { @@ -41,6 +43,7 @@ "happy-dom": "^20.10.3", "jsdom": "^27.0.1", "openapi-typescript": "^7.13.0", + "@playwright/test": "^1.55.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.7" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..3f75ee65 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +const API_PORT = Number(process.env.MOCK_API_PORT || 3100); +const APP_PORT = Number(process.env.E2E_APP_PORT || 3101); +const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102); + +export default defineConfig({ + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [["list"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + use: { + baseURL: `http://localhost:${APP_PORT}`, + ignoreHTTPSErrors: true, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + ], + webServer: [ + { + command: "node ./e2e/mocks/api-server.mjs", + url: `http://localhost:${API_PORT}/health`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + { + command: "npm run dev -- -p " + APP_PORT, + url: `http://localhost:${APP_PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`, + NEXT_PUBLIC_STELLAR_NETWORK: "TESTNET", + NEXT_PUBLIC_SOROBAN_RPC_URL: `https://localhost:${RPC_PORT}/soroban`, + NEXT_PUBLIC_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", + NEXT_PUBLIC_STREAM_CONTRACT_ID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + }, + }, + ], +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 904392f2..abf30dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.3.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -2306,6 +2307,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz", @@ -10337,6 +10354,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",