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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install Stellar CLI
run: cargo install --locked stellar-cli --features opt
run: cargo install --locked stellar-cli
- name: Test contracts
run: cd contracts && cargo test
- name: Build contracts
Expand All @@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: package-lock.json
- name: Reject nested workspace lockfile
Expand Down Expand Up @@ -58,11 +58,11 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: npm
cache-dependency-path: apps/web/package-lock.json
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=web
run: npm ci
- name: Install Playwright browsers
run: npx --workspace=web playwright install --with-deps chromium
- name: Run multi-community browsing flow
Expand Down
237 changes: 90 additions & 147 deletions apps/web/e2e/community-creation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,199 +1,131 @@
import { expect, test, type Page } from "@playwright/test";
import { Keypair, Networks, TransactionBuilder } from "@stellar/stellar-sdk";
import {
MOCK_WALLET_ADDRESS,
configureMockWallet,
signedNetworkPassphrases,
} from "./fixtures/mockWallet";
import {
MOCK_FACTORY_ID,
MOCK_GOVERNOR_ID,
MOCK_NFT_ID,
installSorobanMocks,
} from "./fixtures/sorobanRpc";

const DRAFT = {
name: "Stolla Builders",
symbol: "STBL",
metadataUri: "ipfs://QmCollectionMetadata",
};
ALPHA_GOVERNOR,
ALPHA_NFT,
FACTORY_ID,
TESTNET_PASSPHRASE,
WALLET_ADDRESS,
completeWizardToReview,
installCreationFixtures,
} from "./fixtures";

/**
* Scoped to the application's own markup so the Next.js dev tools button and
* route announcer cannot satisfy a selector.
*/
const wizard = (page: Page) => page.getByRole("main");

async function connectWallet(page: Page) {
const header = page.getByRole("banner");
await page.goto("/community/new");
await header.getByRole("button", { name: "Connect Wallet" }).click();
await expect(header.getByRole("button", { name: "Disconnect" })).toBeVisible();
}

async function completeDraftSteps(page: Page) {
const main = wizard(page);
await main.getByLabel("Community name").fill(DRAFT.name);
await main.getByLabel("Token symbol").fill(DRAFT.symbol);
await main.getByLabel("IPFS metadata URI").fill(DRAFT.metadataUri);

const next = main.getByRole("button", { name: "Next" });
await next.click();
await expect(main.getByLabel("Voting delay (ledgers)")).toBeVisible();

await next.click();
await expect(main.getByText(DRAFT.metadataUri)).toBeVisible();

await next.click();
await expect(simulateButton(page)).toBeVisible();
await expect(main.getByText(MOCK_WALLET_ADDRESS)).toBeVisible();
}

const simulateButton = (page: Page) =>
wizard(page).getByRole("button", { name: "Simulate deployment" });
wizard(page).getByRole("button", { name: /Simulate deployment|Rebuild simulation/i });
const deployButton = (page: Page) =>
wizard(page).getByRole("button", { name: /sign and deploy|awaiting wallet/i });
const alert = (page: Page) => wizard(page).getByRole("alert");
wizard(page).getByRole("button", { name: "Approve and deploy" });
const successHeading = (page: Page) =>
wizard(page).getByRole("heading", {
name: "Community verified in the registry",
});

async function openReview(page: Page) {
await completeWizardToReview(page);
await expect(wizard(page).getByText(WALLET_ADDRESS)).toBeVisible();
await expect(wizard(page).getByText("Creator Guild")).toBeVisible();
}

test.beforeEach(async ({ page }) => {
await configureMockWallet(page);
await installCreationFixtures(page);
});

test("creates a community and ends on a registry-verified success screen", async ({
page,
}) => {
await installSorobanMocks(page);

await connectWallet(page);
await completeDraftSteps(page);
await openReview(page);

await simulateButton(page).click();
await expect(wizard(page).getByText("12345 stroops")).toBeVisible();
await expect(wizard(page).getByText(/12345678 stroops/)).toBeVisible();

await deployButton(page).click();
const success = wizard(page).getByTestId("community-created");
await expect(success).toBeVisible();
await expect(success).toContainText(MOCK_NFT_ID);
await expect(success).toContainText(MOCK_GOVERNOR_ID);

const progress = wizard(page).getByRole("list", { name: "Deployment progress" });
await expect(progress.locator('[data-state="pending"]')).toHaveCount(0);
await expect(successHeading(page)).toBeVisible();
await expect(wizard(page)).toContainText(ALPHA_NFT);
await expect(wizard(page)).toContainText(ALPHA_GOVERNOR);
});

test("sends the expected factory invocation, source account and network", async ({
page,
}) => {
const rpc = await installSorobanMocks(page);

await connectWallet(page);
await completeDraftSteps(page);
await openReview(page);
await simulateButton(page).click();
await expect(wizard(page).getByText("12345 stroops")).toBeVisible();
await expect(wizard(page).getByText(/12345678 stroops/)).toBeVisible();
await deployButton(page).click();
await expect(wizard(page).getByTestId("community-created")).toBeVisible();

const [invocation] = rpc.invocationsOf("create_community");
expect(invocation.contractId).toBe(MOCK_FACTORY_ID);
expect(invocation.sourceAccount).toBe(MOCK_WALLET_ADDRESS);
expect(invocation.args).toEqual([
MOCK_WALLET_ADDRESS,
DRAFT.name,
DRAFT.symbol,
DRAFT.metadataUri,
1,
10000,
BigInt(1),
BigInt(1),
]);

expect(await signedNetworkPassphrases(page)).toEqual([Networks.TESTNET]);

/**
* A Stellar signature covers the network id, so verifying it against the
* testnet passphrase proves the submitted transaction was signed for testnet
* rather than merely labelled as such.
*/
const submitted = TransactionBuilder.fromXDR(
rpc.submittedTransactions[0],
Networks.TESTNET,
);
const signer = Keypair.fromPublicKey(MOCK_WALLET_ADDRESS);
expect(
signer.verify(submitted.hash(), submitted.signatures[0].signature()),
).toBe(true);
await expect(successHeading(page)).toBeVisible();

const diagnostics = await page.evaluate(() => window.__STOLLA_E2E__?.diagnostics);
expect(diagnostics?.submissions).toBe(1);
expect(diagnostics?.invocations).toHaveLength(1);
const invocation = diagnostics?.invocations[0] as {
contractId: string;
method: string;
sourceAccount: string;
networkPassphrase: string;
metadata: { name: string; symbol: string };
};
expect(invocation.contractId).toBe(FACTORY_ID);
expect(invocation.method).toBe("create_community");
expect(invocation.sourceAccount).toBe(WALLET_ADDRESS);
expect(invocation.networkPassphrase).toBe(TESTNET_PASSPHRASE);
expect(invocation.metadata.name).toBe("Creator Guild");
expect(invocation.metadata.symbol).toBe("CREATE");
});

test("returns to a recoverable review state when the wallet rejects", async ({
page,
}) => {
await configureMockWallet(page, { rejectSignature: true });
const rpc = await installSorobanMocks(page);

await connectWallet(page);
await completeDraftSteps(page);
await installCreationFixtures(page, "wallet-rejection");
await openReview(page);
await simulateButton(page).click();
await expect(wizard(page).getByText("12345 stroops")).toBeVisible();
await expect(wizard(page).getByText(/12345678 stroops/)).toBeVisible();

await deployButton(page).click();
await expect(alert(page)).toContainText("declined");
await expect(
wizard(page).getByText(/Wallet approval was declined/i).first(),
).toBeVisible();

expect(rpc.submittedTransactions).toHaveLength(0);
await expect(wizard(page).getByTestId("community-created")).toHaveCount(0);
const diagnostics = await page.evaluate(() => window.__STOLLA_E2E__?.diagnostics);
expect(diagnostics?.submissions).toBe(0);
await expect(successHeading(page)).toHaveCount(0);
await expect(deployButton(page)).toBeEnabled();

await wizard(page).getByRole("button", { name: "3. Review" }).click();
await expect(wizard(page).getByText(DRAFT.name)).toBeVisible();
await expect(wizard(page).getByText(DRAFT.metadataUri)).toBeVisible();
await wizard(page).getByRole("button", { name: "Edit metadata" }).click();
await expect(wizard(page).getByLabel("Community name (required)")).toHaveValue(
"Creator Guild",
);
await wizard(page)
.getByRole("button", { name: "Continue to governance" })
.click();
await wizard(page).getByRole("button", { name: "Review community" }).click();
await expect(wizard(page).getByText("Creator Guild")).toBeVisible();
});

test("blocks signing when simulation fails", async ({ page }) => {
const rpc = await installSorobanMocks(page, {
simulationError: "HostError: contract call failed",
});

await connectWallet(page);
await completeDraftSteps(page);
await installCreationFixtures(page, "simulation-failure");
await openReview(page);
await simulateButton(page).click();

await expect(alert(page)).toContainText("contract call failed");
await expect(deployButton(page)).toBeDisabled();
await expect(wizard(page).getByText(/Run a simulation before deploying/i)).toBeVisible();
await expect(
wizard(page).getByText(/insufficient transaction resources/i).first(),
).toBeVisible();
await expect(deployButton(page)).toHaveCount(0);
await expect(successHeading(page)).toHaveCount(0);

expect(await signedNetworkPassphrases(page)).toEqual([]);
expect(rpc.submittedTransactions).toHaveLength(0);
});

test("withholds success while the registry does not list the pair", async ({
page,
}) => {
const rpc = await installSorobanMocks(page, { registryEmpty: true });

await connectWallet(page);
await completeDraftSteps(page);
await simulateButton(page).click();
await expect(wizard(page).getByText("12345 stroops")).toBeVisible();
await deployButton(page).click();

await expect(alert(page)).toContainText("not visible in the factory registry");
await expect(wizard(page).getByTestId("community-created")).toHaveCount(0);

const progress = wizard(page).getByRole("list", { name: "Deployment progress" });
await expect(progress.locator('[data-stage="verified"]')).toHaveAttribute(
"data-state",
"pending",
);
expect(rpc.invocationsOf("get_community")).toHaveLength(1);
const diagnostics = await page.evaluate(() => window.__STOLLA_E2E__?.diagnostics);
expect(diagnostics?.submissions).toBe(0);
expect(diagnostics?.invocations).toHaveLength(0);
});

test("submits once when the deploy button is clicked repeatedly", async ({
page,
}) => {
const rpc = await installSorobanMocks(page);

await connectWallet(page);
await completeDraftSteps(page);
await openReview(page);
await simulateButton(page).click();
await expect(wizard(page).getByText("12345 stroops")).toBeVisible();
await expect(wizard(page).getByText(/12345678 stroops/)).toBeVisible();

/**
* Three clicks in one tick, before React can re-render the button as
Expand All @@ -206,7 +138,18 @@ test("submits once when the deploy button is clicked repeatedly", async ({
button.click();
});

await expect(wizard(page).getByTestId("community-created")).toBeVisible();
expect(rpc.submittedTransactions).toHaveLength(1);
expect(rpc.invocationsOf("create_community")).toHaveLength(1);
await expect(successHeading(page)).toBeVisible();
const diagnostics = await page.evaluate(() => window.__STOLLA_E2E__?.diagnostics);
expect(diagnostics?.submissions).toBe(1);
expect(diagnostics?.invocations).toHaveLength(1);
});

test("redirects the legacy /community/new route to the canonical wizard", async ({
page,
}) => {
await page.goto("/community/new");
await expect(page).toHaveURL(/\/communities\/create\/?$/);
await expect(
page.getByRole("heading", { name: "Describe your community" }),
).toBeVisible();
});
16 changes: 15 additions & 1 deletion apps/web/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";

export const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015";
export const FACTORY_ID = `C${"A".repeat(55)}`;
Expand Down Expand Up @@ -155,6 +155,11 @@ export async function installCreationFixtures(

export async function completeWizardToReview(page: Page) {
await page.goto("/communities/create");
await expect(
page.getByRole("heading", { name: "Describe your community" }),
).toBeVisible();
// Allow the wizard hydration timeout to settle before editing controlled inputs.
await page.waitForTimeout(50);
await page.getByLabel("Community name (required)").fill("Creator Guild");
await page.getByLabel("NFT symbol (required)").fill("CREATE");
await page
Expand All @@ -166,8 +171,17 @@ export async function completeWizardToReview(page: Page) {
await page
.getByLabel("Community metadata URI (required)")
.fill("https://fixtures.stolla.test/community.json");
await expect(page.getByLabel("Community name (required)")).toHaveValue(
"Creator Guild",
);
await page.getByRole("button", { name: "Continue to governance" }).click();
await expect(
page.getByRole("heading", { name: "Configure governance" }),
).toBeVisible();
await page.getByRole("button", { name: "Review community" }).click();
await expect(
page.getByRole("heading", { name: "Review deployment inputs" }),
).toBeVisible();
await page
.getByLabel(/I confirm that these metadata and governance values/)
.check();
Expand Down
1 change: 1 addition & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const nextConfig: NextConfig = {
root: repositoryRoot,
},
outputFileTracingRoot: repositoryRoot,
allowedDevOrigins: ["127.0.0.1", "localhost"],
};

export default nextConfig;
Loading
Loading