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
153 changes: 153 additions & 0 deletions .github/workflows/dashnote-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions example-apps/dashnote/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions example-apps/dashnote/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
49 changes: 49 additions & 0 deletions example-apps/dashnote/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
167 changes: 167 additions & 0 deletions example-apps/dashnote/test/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,}/);
});
Loading