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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,6 @@ jobs:
- name: Run cross-framework parity smoke tests
run: npx playwright test tests/smoke.spec.ts
working-directory: e2e
- name: Run Guess the Number tests
run: npx playwright test tests/guess-the-number.spec.ts
working-directory: e2e
18 changes: 18 additions & 0 deletions app-lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,21 @@ export function networkStatus(
}
return { appNetwork, walletNetwork, state: "ok", title: "" }
}

/**
* A short, readable message for anything a wallet, RPC or contract call threw.
* Wallets reject with plain `{ code, message }` objects rather than `Error`s,
* and SDK errors can carry a long event log after the first line, so this
* keeps only the first line of whichever message it finds.
*/
export const errorMessage = (error: unknown): string => {
const message =
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
? error.message
: String(error)
const [firstLine = ""] = message.split("\n")
return firstLine.trim() || "Unknown error"
}
2 changes: 1 addition & 1 deletion app-lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// stores, UI components) stays in each template.

export * from "./env" // rpcUrl, networkPassphrase, allowHttp, stellarNetwork, network, labPrefix
export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState
export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState, errorMessage
export * from "./friendbot" // getFriendbotUrl
export * from "./subscription" // subscribeToEvents
export * from "./wallet" // connectWallet, disconnectWallet, profileModal, signTransaction, onWalletChange, fetchBalances, wallet, MappedBalances, WalletState
3 changes: 2 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
"@playwright/test": "^1.49.0",
"@stellar/stellar-sdk": "^16.4.1"
}
}
122 changes: 122 additions & 0 deletions e2e/tests/guess-the-number.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { test, expect, type Page } from "@playwright/test"
import { Keypair, xdr } from "@stellar/stellar-sdk"
import { connectFakeFreighter, DECLINED } from "../util/fake-freighter"
import { CONTRACT_ERROR, stubRpc } from "../util/stub-rpc"

// Guess the Number, from typing a guess to the result the player sees. The
// wallet and the RPC are stand-ins (see `../util`), so these run the same
// against every target and network with no chain, extension or funded account.
//
// Regression guard for stellar-scaffold/ui#285: a guess must reach the
// contract call and the wallet, the contract's answer must reach the player,
// and a failure along the way (declined signature, simulation or contract
// error) must not read as a wrong guess.

const guesser = Keypair.random()
const INCORRECT = "Incorrect guess. Try again!"
const CORRECT = "You got it!"
const COULD_NOT_SUBMIT = /couldn't submit your guess/i

test("a wrong guess is signed, sent, and reported as incorrect", async ({
page,
baseURL,
}) => {
const wallet = await connectFakeFreighter(page, guesser, {
onSign: "approve",
})
const rpc = await stubRpc(page, baseURL!, {
account: guesser.publicKey(),
simulation: "succeeds",
returns: false,
})

await guess(page, "7")

await expect(page.getByText(INCORRECT)).toBeVisible()
expect(wallet.signRequests.map(guessIn)).toEqual([7n])
expect(rpc.sent.map(guessIn)).toEqual([7n])
await expect(page.getByText(COULD_NOT_SUBMIT)).toBeHidden()
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
})

test("a right guess is signed, sent, and reported as a win", async ({
page,
baseURL,
}) => {
await connectFakeFreighter(page, guesser, { onSign: "approve" })
const rpc = await stubRpc(page, baseURL!, {
account: guesser.publicKey(),
simulation: "succeeds",
returns: true,
})

await guess(page, "7")

await expect(page.getByText(CORRECT)).toBeVisible()
expect(rpc.sent.map(guessIn)).toEqual([7n])
await expect(page.getByText(INCORRECT)).toBeHidden()
})

test("a declined signature is reported as an error, not a wrong guess", async ({
page,
baseURL,
}) => {
const wallet = await connectFakeFreighter(page, guesser, {
onSign: "decline",
})
const rpc = await stubRpc(page, baseURL!, {
account: guesser.publicKey(),
simulation: "succeeds",
})

await guess(page, "7")

await expect(page.getByText(COULD_NOT_SUBMIT)).toContainText(DECLINED)
expect(wallet.signRequests.map(guessIn)).toEqual([7n])
expect(rpc.sent).toHaveLength(0)
await expect(page.getByText(INCORRECT)).toBeHidden()
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
})

test("a failed simulation is reported as an error, not a wrong guess", async ({
page,
baseURL,
}) => {
const wallet = await connectFakeFreighter(page, guesser, {
onSign: "approve",
})
const rpc = await stubRpc(page, baseURL!, {
account: guesser.publicKey(),
simulation: "fails",
})

await guess(page, "7")

await expect(page.getByText(COULD_NOT_SUBMIT)).toContainText(CONTRACT_ERROR)
expect(rpc.simulated.map(guessIn)).toEqual([7n])
expect(wallet.signRequests).toHaveLength(0)
await expect(page.getByText(INCORRECT)).toBeHidden()
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
})

/** Load the app, wait for the stand-in wallet to connect, and submit a guess. */
async function guess(page: Page, value: string) {
await page.goto("/")
const address = guesser.publicKey()
const short = `${address.slice(0, 4)}...${address.slice(-4)}`
await expect(page.getByRole("button", { name: short })).toBeVisible()

await page.getByPlaceholder("Guess a number from 1 to 10!").fill(value)
await page.getByRole("button", { name: "Submit" }).click()
}

/** The number passed to `guess` in a base64 transaction envelope. */
function guessIn(envelope: string): bigint {
const [op] = xdr.TransactionEnvelope.fromXDR(envelope, "base64")
.v1()
.tx()
.operations()
const call = op.body().invokeHostFunctionOp().hostFunction().invokeContract()
expect(call.functionName().toString()).toBe("guess")
return call.args()[0].u64().toBigInt()
}
104 changes: 104 additions & 0 deletions e2e/util/fake-freighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Page } from "@playwright/test"
import type { Keypair } from "@stellar/stellar-sdk"
import { KeypairSigner } from "@stellar/stellar-sdk/contract"

export interface FakeFreighterOptions {
/**
* What the player does when the app asks for a signature: `"approve"` signs
* with the keypair, `"decline"` rejects as if they clicked Reject.
*/
onSign: "approve" | "decline"
}

/** The message Freighter rejects with when the player declines to sign. */
export const DECLINED = "User declined access"

/**
* Stand in for the Freighter extension, connected as `keypair`.
*
* Wallets Kit restores the last-used wallet and address from localStorage, and
* `@stellar/freighter-api` talks to the extension over `window.postMessage`, so
* an init script can play the extension's side of that conversation with no
* change to the app. Signing happens here in Node, with the SDK's
* `KeypairSigner`, so the secret key never enters the page.
*
* Returns `signRequests`: every envelope the app asked to have signed, as sent
* (unsigned), whether it was approved or declined.
*/
export async function connectFakeFreighter(
page: Page,
keypair: Keypair,
{ onSign }: FakeFreighterOptions,
) {
const signRequests: string[] = []

await page.exposeFunction(
"__fakeFreighterSign",
async (transactionXdr: string, networkPassphrase: string) => {
signRequests.push(transactionXdr)
if (onSign === "decline") {
return { apiError: { code: -4, message: DECLINED } }
}
try {
const { signedTxXdr } = await new KeypairSigner(
keypair,
networkPassphrase,
).signTransaction(transactionXdr)
return {
signedTransaction: signedTxXdr,
signerAddress: keypair.publicKey(),
}
} catch (e) {
// Reply rather than throw, so the app sees a wallet error instead
// of waiting forever for an answer
return { apiError: { code: -1, message: `Fake Freighter: ${e}` } }
}
},
)

await page.addInitScript((address) => {
type Sign = (xdr: string, passphrase: string) => Promise<object>
const sign = (window as unknown as { __fakeFreighterSign: Sign })
.__fakeFreighterSign

localStorage.setItem("@StellarWalletsKit/selectedModuleId", "freighter")
localStorage.setItem("@StellarWalletsKit/activeAddress", address)
Object.assign(window, { freighter: true })

window.addEventListener("message", (event) => {
const req = event.data
if (
event.source !== window ||
req?.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST"
) {
return
}
const reply = (payload: object) =>
window.postMessage(
{
source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",
messagedId: req.messageId, // sic, freighter-api's spelling
...payload,
},
window.location.origin,
)

switch (req.type) {
case "REQUEST_CONNECTION_STATUS":
return reply({ isConnected: true })
case "SUBMIT_TRANSACTION":
return void sign(req.transactionXdr, req.networkPassphrase).then(
reply,
)
default:
// e.g. REQUEST_NETWORK: the app then treats the network as
// unverified, like most wallets that don't report one
return reply({
apiError: { code: -1, message: `Not supported: ${req.type}` },
})
}
})
}, keypair.publicKey())

return { signRequests }
}
Loading
Loading