diff --git a/.github/workflows/dashnote-e2e.yml b/.github/workflows/dashnote-e2e.yml new file mode 100644 index 0000000..75eb8fd --- /dev/null +++ b/.github/workflows/dashnote-e2e.yml @@ -0,0 +1,153 @@ +name: Dashnote E2E + +on: + push: + branches: [main] + paths: + - 'example-apps/dashnote/**' + - '.github/workflows/dashnote-e2e.yml' + pull_request: + branches: [main] + paths: + - 'example-apps/dashnote/**' + - '.github/workflows/dashnote-e2e.yml' + workflow_dispatch: + inputs: + test_suite: + description: 'Which specs to run' + type: choice + options: + - read-only + - read-write + - all + default: read-only + +permissions: + contents: read + +concurrency: + group: dashnote-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-read-only: + name: e2e (read-only — smoke + settings) + runs-on: ubuntu-latest + timeout-minutes: 20 + if: > + github.event_name != 'workflow_dispatch' || + inputs.test_suite == 'read-only' || + inputs.test_suite == 'all' + defaults: + run: + working-directory: example-apps/dashnote + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + + - name: Install dashnote dependencies + run: npm ci + + - name: Resolve Playwright version + id: playwright-version + run: echo "version=$(node -p "require('./package.json').devDependencies['@playwright/test']")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install browser system deps (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run read-only specs + env: + PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }} + run: npx playwright test smoke.spec.ts settings.spec.ts + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-read-only + path: example-apps/dashnote/playwright-report/ + retention-days: 14 + + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-test-results-read-only + path: example-apps/dashnote/test-results/ + retention-days: 14 + + e2e-read-write: + name: e2e (read-write — auth + notes) + runs-on: ubuntu-latest + timeout-minutes: 60 + if: > + github.event_name == 'workflow_dispatch' && + (inputs.test_suite == 'read-write' || inputs.test_suite == 'all') + concurrency: + group: dashnote-e2e-read-write + cancel-in-progress: false + defaults: + run: + working-directory: example-apps/dashnote + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + + - name: Install dashnote dependencies + run: npm ci + + - name: Resolve Playwright version + id: playwright-version + run: echo "version=$(node -p "require('./package.json').devDependencies['@playwright/test']")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install browser system deps (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run read-write specs + env: + PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }} + run: npx playwright test auth.spec.ts notes.spec.ts + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-read-write + path: example-apps/dashnote/playwright-report/ + retention-days: 14 + + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-test-results-read-write + path: example-apps/dashnote/test-results/ + retention-days: 14 diff --git a/example-apps/dashnote/CLAUDE.md b/example-apps/dashnote/CLAUDE.md index d081179..3a033b7 100644 --- a/example-apps/dashnote/CLAUDE.md +++ b/example-apps/dashnote/CLAUDE.md @@ -12,6 +12,8 @@ React + TypeScript + Vite app for personal notes on Dash Platform testnet. Notes - `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 :5181) +- `npm run test:e2e:ui` — Playwright with the interactive UI runner - `npm run format` / `format:check` — Prettier - `npm run preview` — serve production build locally @@ -26,6 +28,7 @@ React + TypeScript + Vite app for personal notes on Dash Platform testnet. Notes - **[src/dash/types.ts](src/dash/types.ts)** — shared SDK types (`DashSdk`, `DashKeyManager`, query result shapes) used across every dash helper. - **[public/dashnote-lite.html](public/dashnote-lite.html)** — single-file zero-build companion. Read-only Recent notes (with optional owner filter) + Get-by-ID only, loads `@dashevo/evo-sdk` from `esm.sh`, and ships alongside the React app at `<...>/dashnote/dashnote-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 in this flat directory and are named after the subject under test (e.g. `NotesWorkspace.test.tsx`, `SessionContext.test.tsx`, `notesCache.test.ts`) — they are **not** co-located next to source files, and the directory is **not** mirrored against `src/`. Default Vitest env is `node`; component tests opt into DOM with a `// @vitest-environment jsdom` pragma at the top of the file. +- **[test/e2e/](test/e2e/)** — Playwright specs plus shared `fixtures.ts`. Driven by [playwright.config.ts](playwright.config.ts), which loads `PLATFORM_MNEMONIC` from `../../.env` (repo root, with optional `dashnote/.env` override) and auto-starts `npx vite` on port 5181. The suite runs against real testnet — no SDK mocks. Two projects (`chromium-desktop` using `Desktop Chrome` and `chromium-mobile` using `Pixel 7`) so every spec exercises both layouts; viewport-only flows are guarded inline with `test.skip(testInfo.project.name !== "chromium-mobile", …)` rather than living in a dedicated file. 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`). ## Note contract diff --git a/example-apps/dashnote/package.json b/example-apps/dashnote/package.json index 9d84e4d..812a34f 100644 --- a/example-apps/dashnote/package.json +++ b/example-apps/dashnote/package.json @@ -13,6 +13,8 @@ "lint": "eslint .", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "preview": "vite preview" }, "dependencies": { diff --git a/example-apps/dashnote/playwright.config.ts b/example-apps/dashnote/playwright.config.ts new file mode 100644 index 0000000..694f9c2 --- /dev/null +++ b/example-apps/dashnote/playwright.config.ts @@ -0,0 +1,49 @@ +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 dashnote/.env override it if present. +loadEnv({ path: resolve(here, "../../.env") }); +loadEnv({ path: resolve(here, ".env"), override: true }); + +const PORT = 5181; + +export default defineConfig({ + testDir: "./test/e2e", + testMatch: "**/*.spec.ts", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 1, + workers: 1, + timeout: 30_000, + expect: { timeout: 7_500 }, + 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-desktop", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "chromium-mobile", + use: { ...devices["Pixel 7"] }, + }, + ], + + webServer: { + command: `npx vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/example-apps/dashnote/test/e2e/auth.spec.ts b/example-apps/dashnote/test/e2e/auth.spec.ts new file mode 100644 index 0000000..a510f14 --- /dev/null +++ b/example-apps/dashnote/test/e2e/auth.spec.ts @@ -0,0 +1,167 @@ +import { + test, + expect, + HAS_MNEMONIC, + loginViaModal, + navButton, + openIdentityMenu, + openSettingsTab, +} from "./fixtures"; + +test.skip(!HAS_MNEMONIC, "PLATFORM_MNEMONIC not set — skipping auth specs"); +test.describe.configure({ mode: "serial" }); + +// Each test starts from a clean session: no remembered identity, no +// contract override. The base `page` fixture in fixtures.ts already +// waits for the SDK to connect on `/`, so the IdentityCard renders +// "Connected" (readonly) by default. +test.beforeEach(async ({ page }) => { + await page.evaluate(() => { + try { + window.localStorage.removeItem("dashnote.lastIdentity"); + window.localStorage.removeItem("dashnote.contractId"); + } catch { + /* localStorage may be unavailable in some contexts */ + } + }); + await page.reload(); + await expect(page.locator(".conn-dot.connected").first()).toBeVisible({ + timeout: 60_000, + }); +}); + +test("login with a mnemonic, then logout via the IdentityCard menu", async ({ + page, +}) => { + await loginViaModal(page); + + await openIdentityMenu(page); + await page.getByRole("menuitem", { name: /^log out$/i }).click(); + + // No remembered identity → session drops back to readonly. The readonly + // IdentityCard paints "Connected" twice: once as the eyebrow label + // above the card, and once as the inline status text next to the + // connection dot — hence count=2, not 1. + await expect( + page.locator('aside[aria-label="Main navigation"]').getByText("Connected"), + ).toHaveCount(2, { timeout: 30_000 }); +}); + +test("remember-me persists the identity hint across reloads", async ({ + page, +}) => { + await loginViaModal(page, { rememberMe: true }); + + await page.reload(); + // A fresh load drops the keyManager, so the remembered identity boots + // into "browsing" (read-only) rather than authenticated. + await expect( + page.getByText("Browsing (read-only)", { exact: true }), + ).toBeVisible({ timeout: 30_000 }); +}); + +test("forget-this-device via the Settings panel drops back to readonly", async ({ + page, +}) => { + await loginViaModal(page, { rememberMe: true }); + + await openSettingsTab(page); + // "Danger zone" Section only renders when rememberedIdentityId is set. + await page.getByRole("button", { name: /forget this device/i }).click(); + + // After forgetting, this session stays authenticated; the localStorage + // hint is gone, so a reload drops to readonly (not browsing). + await page.reload(); + await expect(page.locator(".conn-dot.connected").first()).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByText("Browsing (read-only)", { exact: true }), + ).toBeHidden(); +}); + +test("forget-this-device via the LoginModal also clears the remembered identity", async ({ + page, +}) => { + await loginViaModal(page, { rememberMe: true }); + + // Log out so the modal can be opened with the rememberedIdentity panel + // visible. Logout-without-forget keeps the hint, dropping to browsing. + await openIdentityMenu(page); + await page.getByRole("menuitem", { name: /^log out$/i }).click(); + await expect( + page.getByText("Browsing (read-only)", { exact: true }), + ).toBeVisible({ timeout: 30_000 }); + + // Open the login modal — the "Forget this device" button is rendered + // beside "Use a different identity" when rememberedIdentityId is set. + // The IdentityCard in browsing state is a button with aria-haspopup="menu" + // (the "Switch identity" menuitem opens the login modal). + await openIdentityMenu(page); + await page.getByRole("menuitem", { name: /switch identity/i }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: /forget this device/i }).click(); + + // The form re-renders without the remembered panel. + await expect( + dialog.locator('[data-testid="remembered-identity-panel"]'), + ).toBeHidden(); + + // Close and reload — readonly state confirms the hint is gone. + await dialog.getByRole("button", { name: /^cancel$/i }).click(); + await page.reload(); + await expect( + page.getByText("Browsing (read-only)", { exact: true }), + ).toBeHidden(); +}); + +test("Switch identity from the IdentityCard menu re-opens the login form", async ({ + page, +}) => { + await loginViaModal(page); + + await openIdentityMenu(page); + await page.getByRole("menuitem", { name: /switch identity/i }).click(); + + // The LoginModal opens with the same form layout regardless of session + // state. Verify it's open and ready to accept a new secret. + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByPlaceholder(/mnemonic phrase|wif/i)).toBeVisible(); + await expect(dialog.getByRole("button", { name: /^Login$/ })).toBeDisabled(); +}); + +test("Settings tab is reachable from both the IdentityCard menu and the sidebar NavButton", async ({ + page, +}) => { + await loginViaModal(page); + + // Menu path. + await openSettingsTab(page); + + // Sidebar NavButton path — switch away first, then back. navButton + // handles the mobile drawer open dance. + await (await navButton(page, /how it works/i)).click(); + await expect( + page.getByRole("heading", { name: /How Dashnote works/i }), + ).toBeVisible(); + + await (await navButton(page, /settings$/i)).click(); + await expect( + page.getByRole("heading", { name: /^Settings$/, level: 1 }), + ).toBeVisible(); +}); + +test("Settings panel displays the identity ID once authenticated", async ({ + page, +}) => { + await loginViaModal(page); + await openSettingsTab(page); + + const idBlock = page.locator('[data-testid="settings-identity-block"]'); + // Identity IDs are base58 strings ~44 chars long; require something + // substantial rather than the loading placeholder. + await expect(idBlock).toContainText(/[0-9A-Za-z]{40,}/); +}); diff --git a/example-apps/dashnote/test/e2e/fixtures.ts b/example-apps/dashnote/test/e2e/fixtures.ts new file mode 100644 index 0000000..74c725f --- /dev/null +++ b/example-apps/dashnote/test/e2e/fixtures.ts @@ -0,0 +1,346 @@ +/** + * Shared Playwright fixtures for dashnote E2E tests. + * + * Runs against real Dash Platform testnet — no SDK mocks. The base `page` + * fixture navigates to `/` and waits for the IdentityCard's connected dot + * (`.conn-dot.connected`) to appear so spec bodies always have a usable + * SDK. We anchor on the dot rather than the text label because the + * readonly card paints "Connected" twice (eyebrow + status line) which + * would trip Playwright's strict-mode matchers. + * + * The sidebar is rendered as `