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
6 changes: 3 additions & 3 deletions control-plane/tests/test_caddy_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def test_the_apps_own_routes_still_reach_the_shell() -> None:
for path in (
"/",
"/signin",
"/pricing",
"/demo",
"/demo/runs",
"/app",
Expand All @@ -108,9 +109,8 @@ def test_the_apps_own_routes_still_reach_the_shell() -> None:

def test_the_router_has_no_top_level_route_the_site_would_404() -> None:
paths = (ROOT / "frontend" / "src" / "app" / "paths.ts").read_text()
top_level = {
m.group(1) for m in re.finditer(r'(?m)^\s+(?:landing|signIn|demo|liveApp):\s*"(/[^"]*)"', paths)
}
route = r'(?m)^\s+(?:landing|signIn|pricing|demo|liveApp):\s*"(/[^"]*)"'
top_level = {m.group(1) for m in re.finditer(route, paths)}
match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet())
assert match and top_level
patterns = match.group(1).split()
Expand Down
2 changes: 1 addition & 1 deletion deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; font-src 'self' data:; connect-src 'self' https://gateway.mergit.site https://store.mergit.site https://sepolia.base.org https://api.github.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
# The app is a single-page app, so its own routes fall back to index.html. Anything else that is not a file is a
# real 404 (not the app shell with a 200, which scanners flag and search engines index as soft 404s).
@spa path / /signin /signin/* /demo /demo/* /app /app/*
@spa path / /signin /signin/* /pricing /demo /demo/* /app /app/*
rewrite @spa /index.html
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
node_modules
dist/
*.tsbuildinfo
test-results/
Expand Down
5 changes: 5 additions & 0 deletions frontend/e2e/live/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ test("a new account chooses between the free trial and a wallet, and the trial h
await page.goto("/app");
const choice = page.getByRole("dialog", { name: "Choose your plan" });
await expect(choice).toBeVisible();
// Three plans: Premium is shown but not available, Pay as you go offers a wallet, and the detail page is linked.
await expect(choice.getByRole("heading", { level: 3 })).toHaveText(["Free", "Premium", "Pay as you go"]);
await expect(choice.getByRole("button", { name: "Coming soon" })).toBeDisabled();
await expect(choice.getByRole("button", { name: /Connect a wallet/ })).toBeEnabled();
await expect(choice.getByRole("link", { name: "Compare plans in detail" })).toHaveAttribute("href", "/pricing");
await choice.getByRole("button", { name: "Start free" }).click();
await expect(choice).toBeHidden();
expect(calls.some((c) => c.path === "/v0/account/trial")).toBe(true);
Expand Down
63 changes: 63 additions & 0 deletions frontend/e2e/pricing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, test as signedIn } from "./fixtures";
import { test as signedOut } from "@playwright/test";

// The limits are the gateway defaults in src/pricing/plans.ts; e2e is a separate TypeScript project, so it spells them.
// Mock project: /pricing is served at the site root, never under /demo, so the dashboard is /app.
signedOut.describe("pricing, signed out", () => {
signedOut("shows the three plans, with Premium visible but not available", async ({ page }) => {
await page.goto("/pricing");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Pricing");
// Headings go h1, then an h2 for each section, then the cards' h3.
await expect(page.getByRole("heading", { level: 2, name: "Plans", exact: true })).toBeAttached();
for (const name of ["Free", "Premium", "Pay as you go"])
await expect(page.getByRole("heading", { level: 3, name })).toBeVisible();
await expect(page.getByText("5 USDC", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Coming soon" })).toBeDisabled();
await expect(page.getByRole("link", { name: "Start free" })).toHaveAttribute("href", "/signin");
});

signedOut("compares the plans in a table with a column each", async ({ page }) => {
await page.goto("/pricing");
const table = page.getByRole("table", { name: "What each plan includes" });
await expect(table.locator("thead").getByRole("columnheader")).toHaveText([
"Feature",
"Free",
"Premium",
"Pay as you go",
]);
await expect(table.getByRole("row", { name: /Wallet needed/ })).toBeVisible();
await expect(table.getByRole("row", { name: /Plans per day/ })).toContainText("3");
});

signedOut("scrolls the table by keyboard, and shows Premium as soon in every row", async ({ page }) => {
await page.goto("/pricing");
const region = page.getByRole("region", { name: "Plan comparison table" });
await expect(region).toHaveAttribute("tabindex", "0");
const premium = region.locator("tbody tr:not(.pr-group) td:nth-child(3)");
for (const cell of await premium.all()) await expect(cell).toContainText("Soon");
});

signedOut("Pay as you go leads to connecting a wallet", async ({ page }) => {
await page.goto("/pricing");
await page.getByRole("button", { name: "Connect a wallet" }).click();
await expect(page).toHaveURL(/\/app$/);
await expect(page.getByRole("dialog", { name: "Connect to Mergit" })).toBeVisible();
});

signedOut("has no sample prices: Premium shows none, and nothing is priced in dollars", async ({ page }) => {
await page.goto("/pricing");
const text = await page.locator("main").innerText();
expect(text).not.toMatch(/\$\s?\d/);
await expect(page.locator(".plan-premium .plan-amount")).toHaveText("Coming soon");
});
});

signedIn.describe("pricing, signed in", () => {
// The plan choice that starts the trial is the dialog over the dashboard, covered in live/account.spec.ts.
signedIn("Free links to the dashboard, and Pay as you go still offers a wallet", async ({ page }) => {
await page.goto("/pricing");
await expect(page.getByRole("link", { name: "Open dashboard" }).first()).toHaveAttribute("href", "/app");
await expect(page.getByRole("button", { name: "Connect a wallet" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Coming soon" })).toBeDisabled();
});
});
1 change: 1 addition & 0 deletions frontend/src/app/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const APP = appBase(IN_DEMO);
export const paths = {
landing: "/",
signIn: "/signin",
pricing: "/pricing",
app: APP,
/** The demo and the real app are different page loads, so these two never depend on where this page is. */
demo: "/demo",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runDetail } from "../mock/data";
import { Agents } from "../screens/agents/Agents";
import { Integrations } from "../screens/Integrations";
import { Leaderboard } from "../screens/Leaderboard";
import { Pricing } from "../screens/Pricing";
import { NewRun } from "../screens/NewRun";
import { NotFound } from "../screens/NotFound";
import { Overview } from "../screens/Overview";
Expand Down Expand Up @@ -43,6 +44,7 @@ export const router = createBrowserRouter([
children: [
{ path: paths.landing, element: IN_DEMO ? <HardNavigate /> : <LandingPage /> },
{ path: paths.signIn, element: IN_DEMO ? <HardNavigate /> : <SignIn /> },
{ path: paths.pricing, element: IN_DEMO ? <HardNavigate /> : <Pricing /> },
{ path: IN_DEMO ? `${paths.liveApp}/*` : `${paths.demo}/*`, element: <HardNavigate /> },
{
path: paths.app,
Expand Down
110 changes: 41 additions & 69 deletions frontend/src/auth/FundingChoice.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { paths } from "../app/paths";
import { useCredits } from "../chain/useCredits";
import { PlanCards } from "../components/PlanCards";
import { Icon } from "../components/Icon";
import { MergitSymbol } from "../components/MergitLogo";
import { startTrial } from "./accountApi";
Expand Down Expand Up @@ -109,71 +110,38 @@ export function FundingChoice() {
{error}
</p>
)}
<div className="plan-grid">
<section className="plan-card" aria-labelledby="plan-free">
<span className="plan-tag">No wallet needed</span>
<h3 id="plan-free" className="plan-name">
Free
</h3>
<p className="plan-price">
<span className="plan-amount">$0</span>
<span className="small c3">test credits</span>
</p>
<ul className="plan-list small c2">
<li>
<Icon name="check" size={14} stroke={2.4} />
Runs on free test credits
</li>
<li>
<Icon name="check" size={14} stroke={2.4} />
Every task is audited
</li>
<li>
<Icon name="check" size={14} stroke={2.4} />
No reputation is earned
</li>
</ul>
<button type="button" className="btn primary block" disabled={busy || exhausted} onClick={trial}>
{exhausted ? "Free credits used" : starting ? "Starting…" : "Start free"}
</button>
</section>
<section className="plan-card" aria-labelledby="plan-payg">
<span className="plan-tag">Wallet</span>
<h3 id="plan-payg" className="plan-name">
Pay as you go
</h3>
<p className="plan-price">
<span className="plan-amount">Per task</span>
<span className="small c3">your own test USDC</span>
</p>
<ul className="plan-list small c2">
<li>
<Icon name="check" size={14} stroke={2.4} />
Each plan is funded from your wallet
</li>
<li>
<Icon name="check" size={14} stroke={2.4} />
Proofs settle to your address
</li>
<li>
<Icon name="check" size={14} stroke={2.4} />
You sign one message; it costs nothing
</li>
</ul>
<button
type="button"
className="btn secondary block"
aria-expanded={walletOpen}
disabled={starting}
onClick={() => {
setWalletOpen((open) => !open);
}}
>
<Icon name="wallet" size={16} />
Connect a wallet
</button>
{walletOpen &&
(wallet.offer ? (
<PlanCards
action={(plan) => {
if (plan.soon)
return (
<button type="button" className="btn secondary block" disabled>
Coming soon
</button>
);
if (plan.id === "free")
return (
<button type="button" className="btn primary block" disabled={busy || exhausted} onClick={trial}>
{exhausted ? "Free credits used" : starting ? "Starting…" : "Start free"}
</button>
);
return (
<button
type="button"
className="btn secondary block"
aria-expanded={walletOpen}
disabled={starting}
onClick={() => {
setWalletOpen((open) => !open);
}}
>
<Icon name="wallet" size={16} />
Connect a wallet
</button>
);
}}
below={(plan) =>
plan.id === "payg" && walletOpen ? (
wallet.offer ? (
<MergePrompt
offer={wallet.offer}
busy={wallet.busy}
Expand All @@ -182,9 +150,13 @@ export function FundingChoice() {
/>
) : (
<WalletPicker busy={wallet.busy} onMetaMask={wallet.connect} />
))}
</section>
</div>
)
) : null
}
/>
<Link to={paths.pricing} className="link small plan-compare">
Compare plans in detail
</Link>
<button type="button" className="link small plan-skip" onClick={dismiss}>
Look around first
</button>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const PATHS = {
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
check: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
tick: '<path d="M20 6 9 17l-5-5"/>',
list: '<path d="M3 12h.01"/><path d="M3 18h.01"/><path d="M3 6h.01"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M8 6h13"/>',
fail: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
running: '<path d="M21 12a9 9 0 1 1-6.22-8.56"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/PlanCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { CSSProperties, ReactNode } from "react";
import "@fontsource/instrument-serif/latin-400.css";
import { PLANS, type Plan } from "../pricing/plans";
import { Icon } from "./Icon";

/**
* The three plans as cards. The caller supplies each card's action and anything under it (the wallet list), so the
* dialog and the pricing page share the look while each keeps its own behaviour.
*/
export function PlanCards({ action, below }: { action: (plan: Plan) => ReactNode; below?: (plan: Plan) => ReactNode }) {
return (
<div className="plan-grid">
{PLANS.map((plan, index) => (
<section
key={plan.id}
className={`plan-card plan-${plan.id}${plan.soon ? " is-soon" : ""}${plan.featured ? " is-featured" : ""}`}
style={{ "--i": index } as CSSProperties}
aria-labelledby={`plan-${plan.id}-name`}
>
<div className="row g8 plan-top">
<span className="plan-tag">{plan.tag}</span>
{plan.soon && <span className="plan-soon">Soon</span>}
{plan.featured && <span className="plan-ribbon">{plan.featured}</span>}
</div>
<h3 id={`plan-${plan.id}-name`} className="plan-name">
{plan.name}
</h3>
<p className="small c2 plan-blurb">{plan.blurb}</p>
<p className="plan-price">
<span className="plan-amount">{plan.amount}</span>
<span className="small c3">{plan.unit}</span>
</p>
<ul className="plan-list small c2">
{plan.includes && (
<li className="plan-includes">
<Icon name="list" size={14} stroke={2.2} />
{plan.includes}
</li>
)}
{plan.features.map((feature) => (
<li key={feature}>
<Icon name="check" size={14} stroke={2.4} />
{feature}
</li>
))}
</ul>
{action(plan)}
{below?.(plan)}
</section>
))}
</div>
);
}
60 changes: 60 additions & 0 deletions frontend/src/pricing/plans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
COMPARISON,
FREE_CREDITS_LABEL,
FREE_CREDITS_USDC,
PLAN_CAP_LABEL,
PLAN_CAP_USDC,
PLANS,
PLANS_PER_DAY,
} from "./plans";

describe("pricing data", () => {
it("has Free, Premium and Pay as you go, and only Premium is unbuilt", () => {
expect(PLANS.map((p) => p.id)).toEqual(["free", "premium", "payg"]);
expect(PLANS.filter((p) => p.soon).map((p) => p.id)).toEqual(["premium"]);
});

it("gives Free the credits the gateway grants by default, and Premium no invented price", () => {
expect(PLANS[0]?.amount).toBe(FREE_CREDITS_LABEL);
expect(PLANS[1]?.amount).not.toMatch(/\d/);
});

it("caps a plan at the free grant, not a smaller number", () => {
// chain/gateway/src/main.rs: CREDIT_PLAN_CAP_USDC_UNITS has no separate default, it defaults to
// FREE_CREDITS_USDC_UNITS itself, after a past bug where a hardcoded cap under the grant let the New
// run page offer a budget the gateway then refused. PLAN_CAP_USDC must track the grant, not a literal.
expect(PLAN_CAP_USDC).toBe(FREE_CREDITS_USDC);
expect(PLAN_CAP_LABEL).toBe(FREE_CREDITS_LABEL);
});

it("sets one built plan apart, never an unbuilt one", () => {
const featured = PLANS.filter((p) => p.featured);
expect(featured.map((p) => p.id)).toEqual(["payg"]);
expect(featured.every((p) => !p.soon)).toBe(true);
});

it("fills every comparison row for every plan", () => {
for (const group of COMPARISON) for (const row of group.rows) expect(row.cells).toHaveLength(PLANS.length);
});

it("shows Premium as soon in every row, since the plan is not built and claims nothing", () => {
for (const group of COMPARISON)
for (const row of group.rows) expect(row.cells[1], row.label).toEqual({ soon: true });
});

it("does not claim reputation is earned yet", () => {
const row = COMPARISON.flatMap((g) => g.rows).find((r) => r.label.includes("reputation"));
expect(row?.cells.some((c) => "yes" in c)).toBe(false);
expect(PLANS[2]?.features.join(" ")).toMatch(/coming soon/);
});

it("states the free limits from the one set of constants, as defaults", () => {
expect(PLANS[0]?.features[0]).toBe(
`By default up to ${PLAN_CAP_LABEL} per plan, ${String(PLANS_PER_DAY)} plans a day`,
);
const cells = COMPARISON.flatMap((g) => g.rows).flatMap((r) => r.cells);
expect(cells).toContainEqual({ text: String(PLANS_PER_DAY) });
expect(cells).toContainEqual({ text: `Up to ${PLAN_CAP_LABEL}` });
});
});
Loading
Loading