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
37 changes: 37 additions & 0 deletions playwright/fixtures/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,40 @@ import {
withWalletApproval,
} from "./freighter";

/**
* If the recording click-highlight init script is loaded on this page,
* spawn a ring at the locator's center and wait 2s before returning so
* the demo viewer can register the action. No-op in verification tests.
*/
async function paceForRecording(page: Page, selector: string): Promise<void> {
const isRecording = await page.evaluate(() =>
!!(globalThis as unknown as { __moonlightClickHighlight?: boolean })
.__moonlightClickHighlight
).catch(() => false);
if (!isRecording) return;
try {
const locator = page.locator(selector).first();
await locator.scrollIntoViewIfNeeded();
const box = await locator.boundingBox();
if (!box) return;
await page.evaluate(
({ x, y }: { x: number; y: number }) => {
(globalThis as unknown as {
__moonlightSpawnRing?: (x: number, y: number) => void;
}).__moonlightSpawnRing?.(x, y);
},
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
);
await page.waitForTimeout(2000);
await page.evaluate(() => {
(globalThis as unknown as { __moonlightSuppressNextAutoRing?: boolean })
.__moonlightSuppressNextAutoRing = true;
});
} catch {
// Best effort — fall through to the click.
}
}

/**
* Connect Freighter wallet on a login page.
*
Expand All @@ -35,6 +69,7 @@ export async function connectWallet(
await page.waitForSelector(connectBtnSelector, { timeout: 15_000 });

// Step 1: Click the connect button to open the wallet picker modal
await paceForRecording(page, connectBtnSelector);
await page.click(connectBtnSelector);
await page.waitForTimeout(1000);

Expand All @@ -47,6 +82,7 @@ export async function connectWallet(
if (
await freighterOption.isVisible({ timeout: 3_000 }).catch(() => false)
) {
await paceForRecording(page, "text=Freighter");
await freighterOption.click();
} else {
// Fallback: look inside the web component's shadow DOM
Expand Down Expand Up @@ -82,6 +118,7 @@ export async function signIn(

// Listen for popup 1 before clicking
const popup1Promise = context.waitForEvent("page", { timeout: 30_000 });
await paceForRecording(page, signInBtnSelector);
await page.click(signInBtnSelector);

const popup1 = await popup1Promise;
Expand Down
57 changes: 29 additions & 28 deletions recording/playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,21 @@ A single recording run goes through these phases, in order:
without seed injection so the full UI onboarding flow records.
3. **Specs run in order** — each spec is a separate Playwright invocation that
loads `run.env` for shared state. Sections 01 and 02 backfill the contract
IDs that 03a/b/c and 04 consume.
IDs that 03 and 04 consume.
4. **Outputs** — videos land under
`runs/<RUN_ID>/videos/<spec-name>/<test-id>.webm`. Each section is its own
file; cut + dub in post.

## Sections

Run order matters: 01 → 02 → 03a → 03b → 03c → 04.
Run order matters: 01 → 02 → 03 → 04.

| # | Spec | What records | Wallet | Reads from run.env | Writes to run.env |
| --- | ----------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------- |
| 01 | `01-council-onboard.spec.ts` | council-console: create council, deploy contracts, fund | Freighter | ADMIN_*, COUNCIL_CONSOLE_URL | CHANNEL_AUTH_ID, PRIVACY_CHANNEL_ID |
| 02 | `02-provider-create-join-approve.spec.ts` | provider-console + council-console approval | Freighter | `PP_*`, `ADMIN_*`, CHANNEL_AUTH_ID | — |
| 03a | `03a-alice-deposit-send.spec.ts` | browser-wallet: onboard, add channel, connect provider, deposit, send | browser-wallet | ALICE_*, PRIVACY_CHANNEL_ID, PROVIDER_PLATFORM_URL | bob-mlxdr.txt artifact |
| 03b | `03b-bob-receive.spec.ts` | browser-wallet: onboard, receive view (captures MLXDR) | browser-wallet | BOB_*, PRIVACY_CHANNEL_ID, PROVIDER_PLATFORM_URL | bob-mlxdr.txt artifact |
| 03c | `03c-alice-withdraw.spec.ts` | browser-wallet: withdraw | browser-wallet | ALICE_*, PRIVACY_CHANNEL_ID | — |
| 04 | `04-dashboard-tour.spec.ts` | dashboard: council list, channel detail, provider, activity | none (uses launchPersistentContext to keep recording rig consistent) | DASHBOARD_URL, COUNCIL_PLATFORM_URL, CHANNEL_AUTH_ID | — |
| # | Spec | What records | Wallet | Reads from run.env | Writes to run.env |
| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------- |
| 01 | `01-council-onboard.spec.ts` | council-console: create council, deploy contracts, fund | Freighter | ADMIN_*, COUNCIL_CONSOLE_URL | CHANNEL_AUTH_ID, PRIVACY_CHANNEL_ID |
| 02 | `02-provider-create-join-approve.spec.ts` | provider-console + council-console approval | Freighter | `PP_*`, `ADMIN_*`, CHANNEL_AUTH_ID | — |
| 03 | `03-private-transfer.spec.ts` | browser-wallet (Bob + Alice in one continuous flow): Bob receive → Alice onboard + deposit + send → Bob withdraw → both wallets flip back to public view to surface on-chain XLM as the closing beat | browser-wallet | ALICE_*, BOB_*, PRIVACY_CHANNEL_ID, PROVIDER_PLATFORM_URL | — |
| 04 | `04-dashboard-tour.spec.ts` | dashboard: council list, council detail, channels + providers, recent activity, transactions, totals | none (uses launchPersistentContext to keep recording rig consistent) | DASHBOARD_URL, COUNCIL_PLATFORM_URL, CHANNEL_AUTH_ID | — |

`run.env` (under `recording/runs/<RUN_ID>/`) is the single shared-state
mechanism between specs:
Expand All @@ -45,9 +43,9 @@ mechanism between specs:
- Section 01 backfills `CHANNEL_AUTH_ID` and `PRIVACY_CHANNEL_ID`.
- Sections 02-04 read those values.

Multi-line blobs (e.g. Bob's receive MLXDR) are persisted as
`runs/<RUN_ID>/<name>.txt` artifacts via `writeRunArtifact`/`readRunArtifact` so
they don't pollute `run.env`.
Section 03 keeps Bob's receive MLXDR in memory across the Bob-receive →
Alice-send sub-beats (the two wallets share one test), so no on-disk artifact
is needed.

## Setup

Expand All @@ -74,7 +72,7 @@ npm run install:browsers
RUN_ID=<id> npm run record

# One section
RUN_ID=<id> npx playwright test specs/03a-alice-deposit-send.spec.ts
RUN_ID=<id> npx playwright test specs/03-private-transfer.spec.ts
```

Videos land under:
Expand All @@ -84,13 +82,11 @@ local-dev/recording/runs/<RUN_ID>/
├── run.env
├── keys.txt
├── .env.seed.user{1,2}
├── bob-mlxdr.txt (only after 03b)
└── videos/
├── 01-council-onboard.spec.ts/<test-id>.webm
├── 02-provider-create-join-approve.spec.ts/<test-id>.webm
├── 03a-alice-deposit-send.spec.ts/<test-id>.webm
├── 03b-bob-receive.spec.ts/<test-id>.webm
├── 03c-alice-withdraw.spec.ts/<test-id>.webm
├── 03-bob/<test-id>.webm (Bob's wallet during section 03)
├── 03-alice/<test-id>.webm (Alice's wallet during section 03)
└── 04-dashboard-tour.spec.ts/<test-id>.webm
```

Expand All @@ -103,12 +99,13 @@ helper manages its own browser context outside this rig's
`playwright.config.ts`, so Playwright's `video: "on"` setting does **not** apply
— these specs run successfully but emit no `.webm` for the Freighter beats.

Sections 03a / 03b / 03c / 04 use the recording rig's own fixtures and do
produce `.webm` files.
Sections 03 / 04 use the recording rig's own fixtures and do produce `.webm`
files (one per wallet window for section 03 — Bob and Alice each get their
own video).

To capture the full six-section run end-to-end, screen-record the display while
`npm run record` executes. The rig already runs non-headless (`headless: false`
in `playwright.config.ts`), so every window is visible.
To capture the full four-section run end-to-end, screen-record the display
while `npm run record` executes. The rig already runs non-headless
(`headless: false` in `playwright.config.ts`), so every window is visible.

```bash
# In one terminal — start your screen recorder (QuickTime "New Screen
Expand All @@ -123,9 +120,10 @@ Tips:
Chromium contexts (admin, pp, alice, bob) as separate windows; window-only
capture will miss handoffs.
- Each context renders at the configured viewport (default 1280x720).
- Total runtime is ~7 minutes for all six specs.
- Total runtime is ~11 minutes for all four specs (01: ~2.4m, 02: ~3.2m, 03:
~5m, 04: ~40s).
- Cursor + window chrome appear in the screen recording. The Playwright `.webm`
outputs (03a–04) intentionally do not, so use whichever source is right for
outputs (03–04) intentionally do not, so use whichever source is right for
the section.

## Tunables
Expand Down Expand Up @@ -163,6 +161,9 @@ the demo doesn't snap to the next step.

## Status

End-to-end validation runs against the local stack with all six specs passing
(`01`, `02`, `03a`, `03b`, `03c`, `04`). Section 04's tour beats
(scroll-to-council, drill-in, provider list, bundle activity) are still TODO.
End-to-end validation runs against the local stack with all four specs passing
(`01`, `02`, `03`, `04`). Section 03 was consolidated from the original
03a/03b/03c split into a single continuous Bob+Alice flow that ends with both
wallets toggled back to public view so the on-chain XLM balances confirm
deposit + withdraw landed. Section 04's tour beats (council list, drill-in,
channels, providers, recent activity, transactions, totals) are wired up.
39 changes: 38 additions & 1 deletion recording/playwright/fixtures/browser-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export const SEL = {
// Confirmation page renders the MLXDR inside a labeled card.
receiveMlxdrLabel: "text=Receiving Address (MLXDR)",
receiveMlxdrValue: "span.font-mono",
// Copy MLXDR — clicking it telegraphs "this is what the sender needs".
receiveCopyButton:
'button:has(svg.tabler-icon-copy), button:has-text("Copy")',

// Private-view home: "Confidential Balance" label sits above the actual
// figure. Polling its sibling for a non-zero number is how we tell the
Expand Down Expand Up @@ -175,8 +178,16 @@ export async function openWalletPopup(
extensionId: string,
): Promise<Page> {
const page = await context.newPage();
await page.setViewportSize({ width: 1280, height: 1080 });
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await page.waitForLoadState("domcontentloaded");
// Override the wallet's hardcoded h-[600px] background so tall content
// doesn't show a scrollbar in recordings.
await page.addStyleTag({
content: `
#root > div { height: 100vh !important; min-height: 100vh !important; }
`,
});
await page.bringToFront();
return page;
}
Expand Down Expand Up @@ -283,6 +294,19 @@ export async function toggleToPrivateView(page: Page): Promise<void> {
await hold(page);
}

/** Toggle home from private to public view. */
export async function toggleToPublicView(page: Page): Promise<void> {
await page.bringToFront();
const toggle = page.locator(SEL.viewModeToggleByShield).first();
if (await toggle.isVisible({ timeout: 5_000 }).catch(() => false)) {
await clickWithPause(toggle);
}
await page.locator(SEL.homePublicBalanceLabel).first().waitFor({
timeout: 10_000,
});
await hold(page);
}

export interface AddChannelOptions {
contractId: string;
channelName: string;
Expand Down Expand Up @@ -414,7 +438,13 @@ export async function deposit(page: Page, opts: AmountOptions): Promise<void> {

// Defaults to deposit mode + Direct method.
await typeSlowly(page.locator(SEL.rampAmountInput).first(), opts.amount);
await clickWithPause(page.locator(SEL.rampReviewDeposit).first());
// Wait for the form to enable the Review button (validation can lag the
// last keystroke). Targets the enabled instance directly.
const reviewDeposit = page
.locator(`${SEL.rampReviewDeposit}:not([disabled])`)
.first();
await reviewDeposit.waitFor({ state: "visible", timeout: 15_000 });
await clickWithPause(reviewDeposit);

// Review screen → Execute Transaction.
const exec = page.locator(SEL.rampExecute).first();
Expand Down Expand Up @@ -482,6 +512,13 @@ export async function showReceive(
.waitFor({ timeout: 30_000 });
await holdAfterSuccess(page);

// Click the Copy button so the viewer sees "this is what the sender needs"
// before we move on. Best-effort — skip if the button isn't surfaced.
const copyBtn = page.locator(SEL.receiveCopyButton).first();
if (await copyBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await clickWithPause(copyBtn);
}

try {
const out = page.locator(SEL.receiveMlxdrValue).first();
if (await out.isVisible({ timeout: 5_000 }).catch(() => false)) {
Expand Down
13 changes: 13 additions & 0 deletions recording/playwright/fixtures/click-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ const INIT_SCRIPT = `
}

window.__moonlightSpawnRing = spawnRing;

// Auto-spawn a ring on every click, in capture phase so we never miss one
// (handlers that stopPropagation can't suppress us). Covers bare .click()
// calls that don't go through clickWithPause.
// clickWithPause sets __moonlightSuppressNextAutoRing right before its
// own click so we don't double-ring (manual ring + auto ring on click).
document.addEventListener("pointerdown", (e) => {
if (window.__moonlightSuppressNextAutoRing) {
window.__moonlightSuppressNextAutoRing = false;
return;
}
spawnRing(e.clientX, e.clientY);
}, true);
})();
`;

Expand Down
21 changes: 13 additions & 8 deletions recording/playwright/fixtures/pacing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ export async function typeSlowly(
}

/**
* Scroll into view + telegraph the target with a focus ring + click.
* The pre-click ring lands the viewer's eye on the target before the click
* actually fires, so the action reads cleanly on the recording.
* Scroll into view + spawn one sustained ring + 2s wait + click.
*
* The ring CSS animation in click-highlight.ts holds visible for ~2s
* before fading, so a single spawn telegraphs the target continuously.
* The suppress flag stops the global pointerdown listener in
* click-highlight.ts from spawning a second ring at click time.
*/
export async function clickWithPause(locator: Locator): Promise<void> {
await locator.scrollIntoViewIfNeeded();
Expand All @@ -65,17 +68,19 @@ export async function clickWithPause(locator: Locator): Promise<void> {
const w = globalThis as unknown as {
__moonlightSpawnRing?: (x: number, y: number) => void;
};
if (typeof w.__moonlightSpawnRing === "function") {
w.__moonlightSpawnRing(x, y);
}
w.__moonlightSpawnRing?.(x, y);
},
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
);
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
}
} catch {
// Detached / cross-origin frame — fall through to the click.
// Detached / cross-origin — fall through to the click.
}
await page.evaluate(() => {
(globalThis as unknown as { __moonlightSuppressNextAutoRing?: boolean })
.__moonlightSuppressNextAutoRing = true;
});
await locator.click();
await beat(page);
}
Expand Down
Loading
Loading