diff --git a/example-apps/dashmint-lab/.gitignore b/example-apps/dashmint-lab/.gitignore index a547bf3..1500d7f 100644 --- a/example-apps/dashmint-lab/.gitignore +++ b/example-apps/dashmint-lab/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Artifacts +playwright-report/ +test-results/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/example-apps/dashmint-lab/CLAUDE.md b/example-apps/dashmint-lab/CLAUDE.md index d266f93..500154f 100644 --- a/example-apps/dashmint-lab/CLAUDE.md +++ b/example-apps/dashmint-lab/CLAUDE.md @@ -8,6 +8,8 @@ React + TypeScript + Vite app for minting, viewing, transferring, and trading NF - `npm run build` — typecheck (`tsc -b`) then bundle - `npm run lint` — ESLint - `npm run test` — Vitest suite in [test/](test/) +- `npm run test:e2e` — Playwright suite in [test/e2e/](test/e2e/) (auto-boots Vite on :5180) +- `npm run test:e2e:ui` — Playwright with the interactive UI runner - `npm run format` / `format:check` — Prettier - `npm run preview` — serve production build locally @@ -23,6 +25,7 @@ React + TypeScript + Vite app for minting, viewing, transferring, and trading NF - **[src/styles/globals.css](src/styles/globals.css)** — Tailwind v4 import + rarity tokens. - **[public/dashmint-lite.html](public/dashmint-lite.html)** — single-file zero-build companion. Read-only Browse cards (with Marketplace-only toggle), loads `@dashevo/evo-sdk` from `esm.sh`, and ships alongside the React app at `<...>/dashmint-lab/dashmint-lite.html` (Vite copies `public/*` into `dist/`). Intentionally self-contained as a learning reference — don't import app code into it. - **[test/](test/)** — Vitest + Testing Library. All test files live here per the `include` pattern in [vite.config.ts](vite.config.ts) and are named after the subject under test (e.g. `CardTile.test.tsx`, `SessionContext.test.tsx`). Default env is `node`; tests that need DOM opt in with `// @vitest-environment jsdom`. +- **[test/e2e/](test/e2e/)** — Playwright specs (`auth`, `browse`, `card`, `how-it-works`, `login-modal`) plus shared `fixtures.ts`. Driven by [playwright.config.ts](playwright.config.ts), which loads `PLATFORM_MNEMONIC` from `../../.env` (repo root, with optional `dashmint-lab/.env` override) and auto-starts `npx vite` on port 5180. The suite runs against real testnet — no SDK mocks. Auth-gated specs sit in `test.describe.configure({ mode: "serial" })` and `test.skip` cleanly when `PLATFORM_MNEMONIC` is unset (via the `HAS_MNEMONIC` flag from `fixtures.ts`). The only chain-mutating spec is the SetPrice round-trip (list → update → unlist) — reversible state, no funds move; transfer / purchase / burn writes are deliberately excluded. ## SDK Patterns diff --git a/example-apps/dashmint-lab/README.md b/example-apps/dashmint-lab/README.md index 493049c..c0a9261 100644 --- a/example-apps/dashmint-lab/README.md +++ b/example-apps/dashmint-lab/README.md @@ -81,6 +81,8 @@ Recommended order for understanding how the app works: [`test/`](test/) uses Vitest + Testing Library, co-located by subject. The default Vitest environment is Node; component tests opt into jsdom per-file with `// @vitest-environment jsdom`. Run with `npm run test`. +[`test/e2e/`](test/e2e/) holds a Playwright suite that runs against real Dash Platform testnet — no mocks. Run with `npm run test:e2e` (or `npm run test:e2e:ui` for the interactive runner). Browse-only specs run without credentials; auth-gated specs activate when `PLATFORM_MNEMONIC` is set in the repo-root `.env` and `test.skip` cleanly otherwise. The only chain-mutating spec is the SetPrice round-trip (list → update → unlist), which is reversible and moves no funds. + ## Deploying to GitHub Pages The project ships with a fork-friendly deploy workflow at the repo root. Pushing the deploy branch triggers a Vite build with `VITE_BASE_PATH` set to the repo name so links resolve under `//`. For local previews of that build, run: diff --git a/example-apps/dashmint-lab/package-lock.json b/example-apps/dashmint-lab/package-lock.json index a55eee6..8d83779 100644 --- a/example-apps/dashmint-lab/package-lock.json +++ b/example-apps/dashmint-lab/package-lock.json @@ -17,11 +17,13 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.59.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "dotenv": "^17.3.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -751,6 +753,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", @@ -2199,6 +2217,19 @@ "license": "MIT", "peer": true }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.339", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", @@ -3399,6 +3430,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", diff --git a/example-apps/dashmint-lab/package.json b/example-apps/dashmint-lab/package.json index ba8f6e0..a83bca9 100644 --- a/example-apps/dashmint-lab/package.json +++ b/example-apps/dashmint-lab/package.json @@ -12,6 +12,8 @@ "format:check": "prettier --check .", "lint": "eslint .", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "preview": "vite preview" }, "dependencies": { @@ -24,6 +26,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.59.1", + "dotenv": "^17.3.1", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", diff --git a/example-apps/dashmint-lab/playwright.config.ts b/example-apps/dashmint-lab/playwright.config.ts new file mode 100644 index 0000000..c7607d5 --- /dev/null +++ b/example-apps/dashmint-lab/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from "@playwright/test"; +import { config as loadEnv } from "dotenv"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Load repo-root .env first (where PLATFORM_MNEMONIC lives for the tutorials), +// then let a local dashmint-lab/.env override it if present. +loadEnv({ path: resolve(here, "../../.env") }); +loadEnv({ path: resolve(here, ".env"), override: true }); + +const PORT = 5180; + +export default defineConfig({ + testDir: "./test/e2e", + testMatch: "**/*.spec.ts", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 1, + workers: 1, + timeout: 120_000, + expect: { timeout: 30_000 }, + reporter: process.env.CI ? "list" : [["list"], ["html", { open: "never" }]], + + use: { + baseURL: `http://localhost:${PORT}`, + trace: "retain-on-failure", + permissions: ["clipboard-read", "clipboard-write"], + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: `npx vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx index bd92486..e34f876 100644 --- a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx +++ b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx @@ -71,8 +71,7 @@ export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { await submitPrice(n); } - const hasCurrentPrice = - !!card && card.$price !== undefined && card.$price !== null; + const hasCurrentPrice = !!card && !!card.$price; return ( { expect(onClose).toHaveBeenCalledTimes(1); }); + it("treats $price === 0n as unlisted (zero is not a valid price)", () => { + mockUseSession.mockReturnValue(sessionValue); + + render( + , + ); + + // Modal renders the unlisted variant: "Set price" title, no "Currently + // listed at …" anchor, no "Remove from sale" button, and the submit + // button reads "List for sale" (not "Update price"). + expect(screen.getByRole("heading", { name: "Set price" })).toBeTruthy(); + expect(screen.queryByText(/currently listed at/i)).toBeNull(); + expect( + screen.queryByRole("button", { name: "Remove from sale" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: "List for sale" })).toBeTruthy(); + }); + it("removes a card from sale and closes after success", async () => { const onClose = vi.fn(); const onPriced = vi.fn(); diff --git a/example-apps/dashmint-lab/test/e2e/auth.spec.ts b/example-apps/dashmint-lab/test/e2e/auth.spec.ts new file mode 100644 index 0000000..f288895 --- /dev/null +++ b/example-apps/dashmint-lab/test/e2e/auth.spec.ts @@ -0,0 +1,448 @@ +import { test, expect, HAS_MNEMONIC, loginViaModal } from "./fixtures"; + +test.describe("Authenticated flows (auth-gated)", () => { + // Login modifies session state; keep this describe serial so it can't race + // against any future write-tier specs sharing the same identity. + test.describe.configure({ mode: "serial" }); + + test.skip( + !HAS_MNEMONIC, + "PLATFORM_MNEMONIC not set — skipping auth-gated specs", + ); + + // ─── After-login UI states ──────────────────────────────────────────────── + + test("Yours tab becomes visible and IdentityCard exposes identity details", async ({ + page, + }) => { + await loginViaModal(page); + + await expect(page.getByRole("button", { name: "Yours" })).toBeVisible(); + + const aside = page.locator("aside"); + // DPNS reverse-lookup is async; wait up to 30s for the @username chip. + await expect(aside.getByText(/^@\w+/).first()).toBeVisible({ + timeout: 30_000, + }); + // truncateId(id, 6) → "<6chars>…<6chars>" in base58. + await expect( + aside.getByText(/[1-9A-HJ-NP-Za-km-z]{6}…[1-9A-HJ-NP-Za-km-z]{6}/), + ).toBeVisible(); + }); + + test("Mint tab no longer shows the unauthenticated overlay", async ({ + page, + }) => { + await loginViaModal(page); + + await page + .getByRole("navigation") + .getByRole("button", { name: /mint/i }) + .click(); + // The unauthenticated overlay is gone. (A different overlay may appear + // for non-contract-owners; we only assert the *unauth* one is hidden.) + await expect( + page.getByText(/login as contract owner to access this feature/i), + ).toBeHidden(); + }); + + // ─── Modal smokes (open / inspect / cancel) ─────────────────────────────── + + test("TransferModal mounts cleanly with card summary and recipient input", async ({ + page, + }) => { + await loginViaModal(page); + + await page.getByRole("button", { name: "Yours" }).click(); + await expect(page.getByText(/loading…/i)).toBeHidden({ timeout: 90_000 }); + + const cards = page.locator("article"); + if ((await cards.count()) === 0) { + test.skip(true, "Signed-in identity owns no cards."); + } + + const firstCard = cards.first(); + await firstCard.getByRole("button", { name: /more actions/i }).click(); + await firstCard.getByRole("button", { name: /^transfer$/i }).click(); + + const dialog = page.getByRole("dialog", { name: /transfer card/i }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("h3")).toBeVisible(); // card title + await expect( + dialog.getByPlaceholder("alice.dash or identity ID"), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: /^Transfer$/ }), + ).toBeVisible(); + + await dialog.getByRole("button", { name: /^Cancel$/ }).click(); + await expect(dialog).toBeHidden(); + }); + + test("PurchaseModal opens for a marketplace listing owned by someone else", async ({ + page, + }) => { + await loginViaModal(page); + + // Read the truncated identity id from the IdentityCard so we can skip + // self-listings. + const myIdSnippet = await page + .locator("aside") + .getByText(/[1-9A-HJ-NP-Za-km-z]{6}…[1-9A-HJ-NP-Za-km-z]{6}/) + .first() + .textContent(); + const myPrefix = myIdSnippet?.split("…")[0]?.trim(); + + await page.getByRole("button", { name: "Marketplace" }).click(); + await expect(page.getByText(/loading…/i)).toBeHidden({ timeout: 90_000 }); + + const cards = page.locator("article"); + const count = await cards.count(); + if (count === 0) { + test.skip(true, "Marketplace is empty."); + } + + let target = -1; + for (let i = 0; i < count; i += 1) { + const text = await cards.nth(i).innerText(); + if (!myPrefix || !text.includes(myPrefix)) { + target = i; + break; + } + } + if (target === -1) { + test.skip( + true, + "All marketplace listings are owned by the test identity.", + ); + } + + const card = cards.nth(target); + await card.getByRole("button", { name: /^buy$/i }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("h3")).toBeVisible(); // card title + await expect( + dialog.getByText(/\d[\d,]*\s*(cr|credits)/i).first(), + ).toBeVisible(); + await expect(dialog.getByRole("button", { name: /^Buy/ })).toBeVisible(); + + await dialog.getByRole("button", { name: /^Cancel$/ }).click(); + await expect(dialog).toBeHidden(); + }); + + test("BurnModal first click flips to confirm without burning the card", async ({ + page, + }) => { + await loginViaModal(page); + + await page.getByRole("button", { name: "Yours" }).click(); + await expect(page.getByText(/loading…/i)).toBeHidden({ timeout: 90_000 }); + + const cards = page.locator("article"); + if ((await cards.count()) === 0) { + test.skip(true, "Signed-in identity owns no cards."); + } + + const firstCard = cards.first(); + const cardTitle = ( + await firstCard.locator("h3").first().textContent() + )?.trim(); + expect(cardTitle).toBeTruthy(); + const countBefore = await cards.count(); + + await firstCard.getByRole("button", { name: /more actions/i }).click(); + await firstCard.getByRole("button", { name: /burn card/i }).click(); + + const dialog = page.getByRole("dialog", { name: /burn card/i }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByText(/this will permanently destroy the card/i), + ).toBeVisible(); + + // First click is a pure UI flip — setConfirmed(true). No SDK call. + await dialog.getByRole("button", { name: /^Burn Card$/ }).click(); + + await expect( + dialog.getByRole("button", { name: /^Confirm Burn$/ }), + ).toBeVisible(); + await expect( + dialog.getByText(/are you sure\? this action is permanent/i), + ).toBeVisible(); + // Success notice must NOT appear — that would mean delete actually ran. + await expect(dialog.getByText(/card burned successfully/i)).toBeHidden(); + // Modal stays open. + await expect(dialog).toBeVisible(); + + await dialog.getByRole("button", { name: /^Cancel$/ }).click(); + await expect(dialog).toBeHidden(); + + // Grid count must be unchanged — if delete had fired, the card would be + // gone after the next refetch and the count would drop by one. Title + // alone is unreliable since multiple cards can share a starter-pool name. + await expect(cards).toHaveCount(countBefore); + await expect( + page.locator("article", { hasText: cardTitle ?? "" }).first(), + ).toBeVisible(); + }); + + // ─── Settings (IdentityCard re-click) + Logout ──────────────────────────── + + test("IdentityCard reopens LoginModal in Settings mode and Logout reverts session", async ({ + page, + }) => { + await loginViaModal(page); + + // Click the IdentityCard region. The whole card is a