Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ jobs:

- name: Start regtest
env:
COMPOSE_PROFILES: webapp-ci
ARBITRUM_E2E_RPC_URL: ${{ vars.ARBITRUM_E2E_RPC_URL }}
ETHEREUM_E2E_RPC_URL: ${{ vars.ETHEREUM_E2E_RPC_URL }}
COMPOSE_PROFILES: webapp-ci,stables-e2e
run: |
git submodule init
git submodule update
Expand All @@ -91,6 +93,8 @@ jobs:

- name: Run Playwright tests
env:
ARBITRUM_E2E_RPC_URL: ${{ vars.ARBITRUM_E2E_RPC_URL }}
ETHEREUM_E2E_RPC_URL: ${{ vars.ETHEREUM_E2E_RPC_URL }}
CI: true
VITE_RSK_LOG_SCAN_ENDPOINT: "http://localhost:8545"
run:
Expand Down
203 changes: 203 additions & 0 deletions e2e/arbitrum/lbtcUsdt0.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type { Page } from "@playwright/test";
import {
type Address,
type PublicClient,
getAddress,
parseAbi,
parseUnits,
} from "viem";

import { config } from "../../src/config";
import { expect, shouldRunArbitrumE2e, test } from "../fixtures/arbitrum";
import {
elementsSendToAddress,
generateBitcoinBlock,
generateLiquidBlock,
verifyRescueFile,
} from "../utils";

const describeArbitrumE2e = (title: string, callback: () => void) => {
if (shouldRunArbitrumE2e()) {
test.describe(title, callback);
} else {
test.describe.skip(title, callback);
}
Comment on lines +19 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate this suite on actual Arbitrum config, not just CI=true.

shouldRunArbitrumE2e() currently enables the suite on any CI worker, but the arbitrumWorker fixture still throws when the RPC env is absent. That is the current CI failure path, so this spec is not self-skipping in shards/jobs that lack the Arbitrum secrets. Please make this suite’s skip condition config-aware or keep it out of CI jobs that do not inject the required RPC vars.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/arbitrum/lbtcUsdt0.spec.ts` around lines 19 - 24, The suite gating
currently uses shouldRunArbitrumE2e() which only checks CI=true, but the
arbitrumWorker fixture still requires RPC env vars; update the skip condition in
describeArbitrumE2e to also check for the presence of the Arbitrum configuration
(e.g. process.env.ARBITRUM_RPC_URL or whatever secret env(s) your arbitrumWorker
expects) or replace shouldRunArbitrumE2e() with a new predicate like
hasArbitrumConfig() that returns true only when the required RPC envs/secrets
exist; ensure describeArbitrumE2e calls that predicate before deciding between
test.describe and test.describe.skip so the spec self-skips when RPC vars are
missing (or alternatively remove this spec from CI shards that don't inject the
RPC vars).

Source: Pipeline failures

};

const erc20Abi = parseAbi([
"function balanceOf(address owner) view returns (uint256)",
]);

const lbtcSendAmount = "0.001";
const quoteRequestTimeout = 60_000;
const quoteReadinessTimeout = 90_000;
const swapClaimTimeout = 75_000;
const swapClaimTestTimeout = 150_000;

const getRegtestTokenAddress = (asset: "USDT0" | "TBTC"): Address => {
const address = config.assets?.[asset]?.token?.address;
if (address === undefined) {
throw new Error(`missing ${asset} token address`);
}

return getAddress(address);
};

const waitForDexQuote = async (args: {
tokenIn: Address;
tokenOut: Address;
amountIn: bigint;
label: string;
}) => {
const params = new URLSearchParams({
tokenIn: args.tokenIn,
tokenOut: args.tokenOut,
amountIn: args.amountIn.toString(),
});
const url = `${config.apiUrl.normal}/v2/quote/ARB/in?${params}`;
const deadline = Date.now() + quoteReadinessTimeout;
let lastError: unknown;

while (Date.now() < deadline) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(quoteRequestTimeout),
});
if (response.ok) {
const quotes = await response.json();
if (Array.isArray(quotes) && quotes.length > 0) {
return;
}
}
lastError = `${response.status} ${response.statusText}`;
} catch (error) {
lastError = error;
}

await new Promise((resolve) => setTimeout(resolve, 1_000));
}

throw new Error(`quote not ready for ${args.label}: ${String(lastError)}`);
};

const chooseAsset = async (page: Page, asset: string) => {
await page.getByTestId(`select-${asset}`).click();
await expect(page.locator(".asset-select-overlay")).toBeHidden({
timeout: 5_000,
});
};

const selectAssets = async (
page: Page,
sendAsset: string,
receiveAsset: string,
) => {
const assetSelectors = page.locator("div[class^='asset asset-']");
await assetSelectors.first().click();
await chooseAsset(page, sendAsset);
await assetSelectors.last().click();
await chooseAsset(page, receiveAsset);
};

const createSwap = async (
page: Page,
sendAsset: string,
receiveAsset: string,
destinationAddress: string,
sendAmount: string,
options?: { skipGoto?: boolean },
) => {
if (options?.skipGoto !== true) {
await page.goto("/");
}
await selectAssets(page, sendAsset, receiveAsset);
await page.getByTestId("onchainAddress").fill(destinationAddress);
await page.getByTestId("sendAmount").fill(sendAmount);

const receiveAmount = page.getByTestId("receiveAmount");
await expect(receiveAmount).not.toHaveValue("", { timeout: 60_000 });
await expect(receiveAmount).not.toHaveValue("0", { timeout: 60_000 });

const createButton = page.getByTestId("create-swap-button");
await expect(createButton).toBeEnabled({ timeout: 60_000 });
await createButton.click();

await verifyRescueFile(page);
await expect(page.locator("div[data-status='swap.created']")).toBeVisible({
timeout: 60_000,
});
};

const getTokenBalance = async (
publicClient: PublicClient,
token: Address,
owner: Address,
) =>
await publicClient.readContract({
address: token,
abi: erc20Abi,
functionName: "balanceOf",
args: [owner],
});

describeArbitrumE2e("Arbitrum stablecoin e2e", () => {
test.describe.configure({ mode: "serial" });

test.beforeEach(async () => {
await generateBitcoinBlock();
await generateLiquidBlock();
});

test("claims an L-BTC to USDT0-Arbitrum chain swap", async ({
arbitrum,
recipientAddress,
page,
}) => {
test.setTimeout(swapClaimTestTimeout);

const token = getRegtestTokenAddress("USDT0");
const balanceBefore = await getTokenBalance(
arbitrum.publicClient,
token,
recipientAddress,
);

await waitForDexQuote({
tokenIn: getRegtestTokenAddress("TBTC"),
tokenOut: getRegtestTokenAddress("USDT0"),
amountIn: parseUnits(lbtcSendAmount, 18),
label: "TBTC -> USDT0",
});
await createSwap(
page,
"L-BTC",
"USDT0",
recipientAddress,
lbtcSendAmount,
);

await page
.locator("div[data-testid='pay-onchain-buttons']")
.getByText("address")
.click();

const lockupAddress = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(lockupAddress).toBeDefined();
Comment on lines +179 to +187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wait for a non-empty copied lockup address before funding.

The clipboard is read immediately after the click, and toBeDefined() still passes for an empty or stale string. That makes this step race the copy handler and shifts the failure to sendtoaddress instead of the actual clipboard handoff.

Suggested fix
         await page
             .locator("div[data-testid='pay-onchain-buttons']")
             .getByText("address")
             .click();

-        const lockupAddress = await page.evaluate(() =>
-            navigator.clipboard.readText(),
-        );
-        expect(lockupAddress).toBeDefined();
+        await expect
+            .poll(
+                () => page.evaluate(() => navigator.clipboard.readText()),
+                { timeout: 5_000 },
+            )
+            .not.toBe("");
+
+        const lockupAddress = await page.evaluate(() =>
+            navigator.clipboard.readText(),
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/arbitrum/lbtcUsdt0.spec.ts` around lines 179 - 187, After clicking the
"address" button (locator:
page.locator("div[data-testid='pay-onchain-buttons']").getByText("address").click()),
wait until the clipboard actually contains a non-empty lockup address before
reading into lockupAddress; replace the immediate navigator.clipboard.readText()
with a wait/poll (e.g. page.waitForFunction or a short loop with retries) that
calls navigator.clipboard.readText() and resolves only when the returned string
is non-empty (and optionally trimmed) so that the subsequent
expect(lockupAddress) assertion validates the real copied value rather than a
stale/empty string.


await elementsSendToAddress(lockupAddress, lbtcSendAmount);
await generateLiquidBlock();

await expect(
page.locator("div[data-status='transaction.claimed']"),
).toBeVisible({ timeout: swapClaimTimeout });

const balanceAfter = await getTokenBalance(
arbitrum.publicClient,
token,
recipientAddress,
);
expect(balanceAfter).toBeGreaterThan(balanceBefore);
});
});
Loading
Loading