Public: anyone can read this; the crew can change it.
A person or an assistant given only the link knows the next step and sees our pictures, not another product's #11691
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11654-a-person-or-an-assistant-given-only-the → main 6 commits fork
16 files
changed +193
−32
docs/features/start-my-forge.md +4
| @@ -20,3 +20,7 @@ The form carries `from=home`. If the door refuses a stranger's start (rate limit | ||
| 20 | 20 | ## Three screens, no link to copy |
| 21 | 21 | |
| 22 | 22 | When the door accepts a start from the home page (`from=home`, signed out), the browser is not shown the claim link. If the new forge already answers, it gets a 303 straight to its `/claim#<code>` form; if it is still booting, a "Making your forge" page polls `GET /forges/<name>/ready` and goes on by itself (`walkOn` in `src/web/pages/new-forge.ts`). On the claim form the person picks a username and password and lands signed in as the forge's admin. Screens: `/`, the claim form, the forge. Test: `test/stranger-three-screens.test.ts` (in the gate, which runs every test file). |
| 23 | ||
| 24 | ## One path, one button name | |
| 25 | ||
| 26 | `SITE_PATH` and `START_BUTTON` (`src/web/site_line.ts`) hold the path as short sentences and the button's one name. The sentence is the meta description of `/`, `/pricing` and `/agents`, and sits in `/llms.txt` beside a Plans table made from the plans setting (`src/web/offer.ts`, also the `Offer` data on `/pricing`); the home page itself keeps one sentence. `/`, `/pricing` (the Free plan's button, posted to `/forges/start`), `/agents` and the terminal command all say "Start my forge", and all tell the person they choose a username and password next. Test: `test/share-path.test.ts`. |
src/web/layout.ts +6 −8
| @@ -748,7 +748,7 @@ function footer(ctx: Ctx, own?: Html): Html { | ||
| 748 | 748 | function pageDescription(options: PageOptions): string { |
| 749 | 749 | const own = options.description?.trim().split(/\r?\n/)[0]?.trim() ?? ""; |
| 750 | 750 | const line = own === "" ? SITE_LINE : own; |
| 751 | return line.length <= 200 ? line : `${line.slice(0, 197).trimEnd()}…`; | |
| 751 | return line.length <= 360 ? line : `${line.slice(0, 357).trimEnd()}…`; | |
| 752 | 752 | } |
| 753 | 753 | |
| 754 | 754 | /** |
| @@ -758,7 +758,7 @@ function pageDescription(options: PageOptions): string { | ||
| 758 | 758 | export function tabTitle(title: string): string { |
| 759 | 759 | const own = title.replace(/(?: · forge)+$/, ""); |
| 760 | 760 | if (own === "" || own === "forge") return "forge"; |
| 761 | return own.startsWith("forge: ") ? own : `${own} · forge`; | |
| 761 | return own.startsWith("forge: ") || own.startsWith("Open Platform Forge: ") ? own : `${own} · forge`; | |
| 762 | 762 | } |
| 763 | 763 | |
| 764 | 764 | export function page(options: PageOptions, status = 200): Response { |
| @@ -766,6 +766,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 766 | 766 | // inline <style> is admitted by the hash of its own text, so the same page |
| 767 | 767 | // is the same bytes for everyone and can be cached (docs/web.md). |
| 768 | 768 | const { ctx } = options; |
| 769 | const shareImage = `${ctx.url.origin}${options.ogImage !== undefined && options.ogImage.startsWith("/og/") ? options.ogImage : "/og/site.png"}`; | |
| 769 | 770 | const theme = ctx.theme ?? options.theme; |
| 770 | 771 | const cookieFlash = readFlash(ctx.request); |
| 771 | 772 | const flash = options.flash ?? cookieFlash; |
| @@ -799,7 +800,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 799 | 800 | /> |
| 800 | 801 | <title>${tabTitle(options.title)}</title> |
| 801 | 802 | <meta name="description" content="${pageDescription(options)}" /> |
| 802 | <meta property="og:site_name" content="forge" /> | |
| 803 | <meta property="og:site_name" content="Open Platform Forge" /> | |
| 803 | 804 | <meta property="og:type" content="website" /> |
| 804 | 805 | <meta property="og:title" content="${options.title}" /> |
| 805 | 806 | <meta property="og:description" content="${pageDescription(options)}" /> |
| @@ -820,14 +821,11 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 820 | 821 | } |
| 821 | 822 | <meta |
| 822 | 823 | property="og:image" |
| 823 | content="${ctx.url.origin}${ | |
| 824 | options.ogImage !== undefined && options.ogImage.startsWith("/og/") | |
| 825 | ? options.ogImage | |
| 826 | : "/og/site.png" | |
| 827 | }" | |
| 824 | content="${shareImage}" | |
| 828 | 825 | /> |
| 829 | 826 | <meta property="og:image:width" content="1200" /> |
| 830 | 827 | <meta property="og:image:height" content="630" /> |
| 828 | <meta name="twitter:image" content="${shareImage}" /> | |
| 831 | 829 | <meta name="twitter:card" content="summary_large_image" /> |
| 832 | 830 | <link rel="manifest" href="/manifest.webmanifest" /> |
| 833 | 831 | <link |
src/web/offer.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The plan facts an assistant quotes (CPU, memory, price, credit), as data: | |
| 3 | * schema.org `Offer`s for a page and a markdown table for /llms.txt, both made | |
| 4 | * from the plans setting so they cannot disagree with the pricing page. | |
| 5 | */ | |
| 6 | import { SIZES } from "../spawn/sizes.ts"; | |
| 7 | import type { Plan } from "../settings/plans.ts"; | |
| 8 | ||
| 9 | const gb = (mib: number): number => mib / 1024; | |
| 10 | ||
| 11 | /** One schema.org Offer per plan, with its size and monthly crew credit as plain properties. */ | |
| 12 | export function offerData(plans: readonly Plan[], origin: string): object[] { | |
| 13 | return plans.map((p) => { | |
| 14 | const s = SIZES[p.size]; | |
| 15 | return { | |
| 16 | "@type": "Offer", | |
| 17 | name: p.name, | |
| 18 | description: p.says, | |
| 19 | price: String(p.priceUsd), | |
| 20 | priceCurrency: "USD", | |
| 21 | url: `${origin}/pricing`, | |
| 22 | additionalProperty: [ | |
| 23 | { "@type": "PropertyValue", name: "CPUs", value: s.cpu }, | |
| 24 | { "@type": "PropertyValue", name: "Memory (GB)", value: gb(s.memoryMiB) }, | |
| 25 | { "@type": "PropertyValue", name: "Disk (GB)", value: gb(s.diskMiB) }, | |
| 26 | { "@type": "PropertyValue", name: "Crew credit (USD a month)", value: p.creditUsd }, | |
| 27 | ], | |
| 28 | }; | |
| 29 | }); | |
| 30 | } | |
| 31 | ||
| 32 | /** The same facts as a markdown table. */ | |
| 33 | export function offerTable(plans: readonly Plan[]): string[] { | |
| 34 | return [ | |
| 35 | "| Plan | USD/month | CPUs | RAM GB | Disk GB | Credit USD |", | |
| 36 | "| --- | --- | --- | --- | --- | --- |", | |
| 37 | ...plans.map((p) => { | |
| 38 | const s = SIZES[p.size]; | |
| 39 | return `| ${p.name} | ${p.priceUsd} | ${s.cpu} | ${gb(s.memoryMiB)} | ${gb(s.diskMiB)} | ${p.creditUsd} |`; | |
| 40 | }), | |
| 41 | ]; | |
| 42 | } |
src/web/pages/agents.ts +4 −4
| @@ -13,6 +13,7 @@ import { html, type Html } from "../html.ts"; | ||
| 13 | 13 | import { btn, disclosure, field as formField } from "../ui.ts"; |
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | 15 | import { renderMarkdown } from "../markdown.ts"; |
| 16 | import { START_BUTTON, SITE_PATH } from "../site_line.ts"; | |
| 16 | 17 | import { llmsBody } from "../standards.ts"; |
| 17 | 18 | |
| 18 | 19 | /** An address in prose, outside code, made a link; a template (`{owner}`) stays words. */ |
| @@ -67,16 +68,15 @@ export function agentsPage(ctx: Ctx): Response { | ||
| 67 | 68 | return timePhase(ctx.request, "render", () => page({ |
| 68 | 69 | ctx, |
| 69 | 70 | title: "For AI assistants", |
| 70 | description: | |
| 71 | "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.", | |
| 71 | description: SITE_PATH(ctx.url.origin), | |
| 72 | 72 | body: html`${pageHead("For AI assistants", { |
| 73 | 73 | sub: html`People start at <a href="/">home</a>. Assistants: <a href="/llms.txt">/llms.txt</a>`, |
| 74 | 74 | })} |
| 75 | 75 | <section class="stack" aria-labelledby="agent-forge-h"> |
| 76 | 76 | <h2 id="agent-forge-h">Get a forge of your own</h2> |
| 77 | <form method="post" action="/forges/new" class="stack" id="agent-forge"> | |
| 77 | <form method="post" action="/forges/start" class="stack" id="agent-forge"> | |
| 78 | 78 | ${formField({ label: "Name", name: "name", required: true })} |
| 79 | ${btn("Make my forge", { variant: "primary" })} | |
| 79 | ${btn(START_BUTTON, { variant: "primary" })} | |
| 80 | 80 | </form> |
| 81 | 81 | </section> |
| 82 | 82 | <div class="prose break"><ol id="agent-steps" aria-label="Build an app with no account">${firstSteps(body).map((step) => html`<li>${stepLine(step)}</li>`)}</ol></div> |
src/web/pages/pricing.ts +12 −2
| @@ -20,6 +20,9 @@ import { notFoundPage, pageHead } from "../chrome.ts"; | ||
| 20 | 20 | import { field, readForm } from "../form.ts"; |
| 21 | 21 | import { html, type Html } from "../html.ts"; |
| 22 | 22 | import { page, redirect } from "../layout.ts"; |
| 23 | import { START_BUTTON, SITE_PATH } from "../site_line.ts"; | |
| 24 | import { offerData } from "../offer.ts"; | |
| 25 | import { safeJson } from "../structured_data.ts"; | |
| 23 | 26 | import { btn, field as formField, row } from "../ui.ts"; |
| 24 | 27 | import { claimLinkBody } from "./new-forge.ts"; |
| 25 | 28 | |
| @@ -43,7 +46,7 @@ function plansView(ctx: Ctx, values: Values, errors: Partial<Record<Refusal["fie | ||
| 43 | 46 | title: html`${p.name}`, |
| 44 | 47 | meta: html`${sizeWords(p.size)}. ${p.says}${p.creditUsd > 0 && selling ? html` $${p.creditUsd} of crew credit each month.` : null}`, |
| 45 | 48 | trail: free |
| 46 | ? html`<span class="muted">Free</span> ${btn("Start free", { name: "plan", value: p.key })}` | |
| 49 | ? html`<span class="muted">Free</span> ${btn(START_BUTTON, { formaction: "/forges/start" })}` | |
| 47 | 50 | : paid |
| 48 | 51 | ? html`<span>$${p.priceUsd} a month</span> ${btn(`Get ${p.name}`, { name: "plan", value: p.key, variant: "primary" })}` |
| 49 | 52 | : html`<span class="muted">Ask the operator</span>`, |
| @@ -84,7 +87,14 @@ export function pricingPage(ctx: Ctx): Response { | ||
| 84 | 87 | if (!isBuyer(ctx)) return notFoundPage(ctx); |
| 85 | 88 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 86 | 89 | const rendering = performance.now(); |
| 87 | const response = page({ ctx, title: "Get your own forge", body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}) }); | |
| 90 | const plans = getPlans(ctx.app.db); | |
| 91 | const response = page({ | |
| 92 | ctx, | |
| 93 | title: "Open Platform Forge: pricing and your own forge", | |
| 94 | description: SITE_PATH(ctx.url.origin), | |
| 95 | jsonLd: safeJson({ "@context": "https://schema.org", "@type": "Product", name: "Open Platform Forge", url: `${ctx.url.origin}/`, offers: offerData(plans, ctx.url.origin) }), | |
| 96 | body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}), | |
| 97 | }); | |
| 88 | 98 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 89 | 99 | return response; |
| 90 | 100 | } |
src/web/site_line.ts +10
| @@ -1,2 +1,12 @@ | ||
| 1 | 1 | /** The one sentence for what the forge is: manifest, meta and og:description, front page. */ |
| 2 | 2 | export const SITE_LINE = "Your software and the crew that builds it, in one place."; |
| 3 | ||
| 4 | /** The one button a person presses to get a forge; every page that offers one uses this name. */ | |
| 5 | export const START_BUTTON = "Start my forge"; | |
| 6 | ||
| 7 | /** The one path from a link to an app, in one line; no other path is described anywhere. */ | |
| 8 | export const SITE_PATH = (origin: string): string => | |
| 9 | `Open ${origin}. Type a name and press ${START_BUTTON}. Choose a username and password. Type what you want built. Your app is live a minute later.`; | |
| 10 | ||
| 11 | /** What this is, in one sentence, with the full name. */ | |
| 12 | export const SITE_WHAT = "Open Platform Forge gives you a forge of your own where an AI crew builds your apps."; |
src/web/standards.ts +15 −6
| @@ -42,7 +42,9 @@ import { publicForgeRepo } from "../core/forge_repo.ts"; | ||
| 42 | 42 | import type { Ctx } from "../http/context.ts"; |
| 43 | 43 | import { notFound } from "../http/respond.ts"; |
| 44 | 44 | import { SHARE_TARGET } from "./share_target.ts"; |
| 45 | import { SITE_LINE } from "./site_line.ts"; | |
| 45 | import { offerTable } from "./offer.ts"; | |
| 46 | import { getPlans } from "../settings/plans.ts"; | |
| 47 | import { SITE_PATH, SITE_LINE } from "./site_line.ts"; | |
| 46 | 48 | import { holds } from "./feeds.ts"; |
| 47 | 49 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 48 | 50 | import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; |
| @@ -386,7 +388,7 @@ function runningSentence(): string { | ||
| 386 | 388 | } |
| 387 | 389 | |
| 388 | 390 | /** llms.txt and its ETag per origin, valid while the listing read, the build, the tell command and the plugin source being public are the same. */ |
| 389 | const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: string | undefined; body: string; etag: string }>(); | |
| 391 | const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: string | undefined; plans: string; body: string; etag: string }>(); | |
| 390 | 392 | |
| 391 | 393 | /** |
| 392 | 394 | * GET /llms.txt — the forge as an agent should meet it (llmstxt.org): what |
| @@ -418,11 +420,12 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } { | ||
| 418 | 420 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 419 | 421 | const signedIn = ctx.auth.user !== undefined; |
| 420 | 422 | const key = `${o} ${signedIn}`; |
| 423 | const plans = JSON.stringify(getPlans(ctx.app.db)); | |
| 421 | 424 | let kept = llmsBodies.get(key); |
| 422 | if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins) { | |
| 425 | if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins || kept.plans !== plans) { | |
| 423 | 426 | const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins)); |
| 424 | 427 | const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`); |
| 425 | kept = { signedIn, repos, running, tell, plugins, body, etag }; | |
| 428 | kept = { signedIn, repos, running, tell, plugins, plans, body, etag }; | |
| 426 | 429 | // Origins come from the Host header: keep a handful, not one per guess. |
| 427 | 430 | if (llmsBodies.size >= 16) llmsBodies.clear(); |
| 428 | 431 | llmsBodies.set(key, kept); |
| @@ -448,7 +451,9 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 448 | 451 | "", |
| 449 | 452 | running, |
| 450 | 453 | "", |
| 451 | `> ${SITE_LINE} A self-hosted forge in one Bun process and one SQLite file that hosts code and runs apps, with AI agents that build. Forges connect to one another. Part of Open Platform (https://www.open-platform.sh). Anyone may read a public repository, and report a problem in it without an account.`, | |
| 454 | SITE_PATH(o), | |
| 455 | "", | |
| 456 | `> ${SITE_LINE} A self-hosted forge in one Bun process and one SQLite file that hosts code and runs apps, with AI agents that build. Forges connect. Part of Open Platform (https://www.open-platform.sh). Anyone may read a public repository and report a problem in it.`, | |
| 452 | 457 | "", |
| 453 | 458 | "## Start here", |
| 454 | 459 | "", |
| @@ -456,10 +461,14 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 456 | 461 | "", |
| 457 | 462 | `- Build now, no account: \`curl -sX POST ${o}/api/v1/start -d '{"name":"my-app"}'\` -> {user, token, repo, git, app, claim}. Clone git.url (user:token@), \`git push\` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.`, |
| 458 | 463 | `- Or with an account: sign up at ${o}/signup, then follow "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}. If /signup says 403, use "Report a problem".`, |
| 459 | `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, plans on ${o}: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 464 | `- Run it yourself instead: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or hosted (plans below): ${o}/forges/new, or MCP \`spawn\` with the person's consent. A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.`, | |
| 460 | 465 | `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`, |
| 461 | 466 | `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`, |
| 462 | 467 | "", |
| 468 | "## Plans", | |
| 469 | "", | |
| 470 | ...offerTable(getPlans(ctx.app.db)), | |
| 471 | "", | |
| 463 | 472 | "## Report a problem (no account)", |
| 464 | 473 | "", |
| 465 | 474 | `- Page: ${o}/{owner}/{name}/report`, |
src/web/studio/home.ts +3 −2
| @@ -45,6 +45,7 @@ import { liveRegionsJs } from "../client.ts"; | ||
| 45 | 45 | import { html, when, type Html } from "../html.ts"; |
| 46 | 46 | import { fragment, page, redirect } from "../layout.ts"; |
| 47 | 47 | import { START_REFUSALS, startRefusalIn } from "../start_refusal.ts"; |
| 48 | import { SITE_PATH, SITE_WHAT } from "../site_line.ts"; | |
| 48 | 49 | import { siteStructuredData } from "../structured_data.ts"; |
| 49 | 50 | import { running } from "../../kernel/version.ts"; |
| 50 | 51 | import { homeMilestones } from "./milestone_lines.ts"; |
| @@ -927,8 +928,8 @@ bun run src/main.ts</code></pre> | ||
| 927 | 928 | </div>`; |
| 928 | 929 | const res = page({ |
| 929 | 930 | ctx, |
| 930 | title: "forge: software for what you're building, yours to keep", | |
| 931 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", | |
| 931 | title: "Open Platform Forge: software for what you're building, yours to keep", | |
| 932 | description: `${SITE_WHAT} ${SITE_PATH(origin)}`, | |
| 932 | 933 | body, |
| 933 | 934 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}\n${JS_FLAG_JS}`, |
| 934 | 935 | jsonLd: siteStructuredData(origin, running().version), |
test/agents-page-language.test.ts +1 −1
| @@ -29,7 +29,7 @@ test("/agents has no card, keeps the form, and says what /llms.txt says", async | ||
| 29 | 29 | expect(main).not.toContain('class="card'); |
| 30 | 30 | expect(main).not.toContain("card-body"); |
| 31 | 31 | expect(main).toContain('<section class="stack" aria-labelledby="agent-forge-h">'); |
| 32 | expect(main).toMatch(/<form method="post" action="\/forges\/new"/); | |
| 32 | expect(main).toMatch(/<form method="post" action="\/forges\/start"/); | |
| 33 | 33 | expect(main).toContain("<h2 id=\"agent-forge-h\">Get a forge of your own</h2>"); |
| 34 | 34 | const llms = await (await fetch(servers.url + "/llms.txt")).text(); |
| 35 | 35 | // Plain prose lines (letters, spaces, commas, periods) must appear verbatim on the page. |
test/agents-page.test.ts +2 −3
| @@ -78,7 +78,7 @@ test("above the fold the page is the form and three steps; the whole guide is be | ||
| 78 | 78 | expect(open).toBeGreaterThan(0); |
| 79 | 79 | const outside = main.slice(0, open); |
| 80 | 80 | expect(main.match(/<details/g)?.length).toBe(1); |
| 81 | expect(outside).toContain('action="/forges/new"'); | |
| 81 | expect(outside).toContain('action="/forges/start"'); | |
| 82 | 82 | expect(outside).toContain('href="/llms.txt"'); |
| 83 | 83 | // Real list numbering: an <ol> inside .prose, no hand-written "1." in the items. |
| 84 | 84 | const steps = outside.slice(outside.indexOf('<ol id="agent-steps"')); |
| @@ -102,8 +102,7 @@ test("above the fold the page is the form and three steps; the whole guide is be | ||
| 102 | 102 | expect(inside).toContain(guideHtml(plain).toString()); |
| 103 | 103 | for (const sentence of [ |
| 104 | 104 | "Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.", |
| 105 | "Or a hosted one, plans on", | |
| 106 | "It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.", | |
| 105 | "A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.", | |
| 107 | 106 | ]) { |
| 108 | 107 | expect(plain).toContain(sentence); |
| 109 | 108 | expect(inside).toContain(sentence); |
test/forge-sleep.test.ts +1 −1
| @@ -319,7 +319,7 @@ describe("a hosted forge scales to zero", () => { | ||
| 319 | 319 | test("/agents makes a forge in one step: its form posts the name straight to the spawn", async () => { |
| 320 | 320 | const aliceToken = tokenFor(parent, "alice", "admin"); |
| 321 | 321 | const agents = await (await fetch(`${servers.url}/agents`)).text(); |
| 322 | expect(agents).toContain('<form method="post" action="/forges/new" class="stack" id="agent-forge">'); | |
| 322 | expect(agents).toContain('<form method="post" action="/forges/start" class="stack" id="agent-forge">'); | |
| 323 | 323 | // What that form sends, and nothing else: the forge is made, its claim link in the answer. |
| 324 | 324 | const made = await fetch(`${servers.url}/forges/new`, { |
| 325 | 325 | method: "POST", |
test/home-start-forge.test.ts +1 −1
| @@ -39,7 +39,7 @@ test("a visitor with no cookies sees the start form on /", async () => { | ||
| 39 | 39 | expect(form).toContain('autocapitalize="none"'); |
| 40 | 40 | expect(form).toContain('autocomplete="off"'); |
| 41 | 41 | expect(form).not.toContain("class=\"hint\""); |
| 42 | expect(body.match(/Start my forge/g)).toHaveLength(1); | |
| 42 | expect(body.match(/Start my forge\s*<\/button>/g)).toHaveLength(1); | |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | 45 | test("the first view has one primary button, and the first sentence is one", async () => { |
test/page-title.test.ts +3 −2
| @@ -27,8 +27,8 @@ const page = async (path: string) => (await fetch(`${servers.url}${path}`)).text | ||
| 27 | 27 | test("the front page names the forge once", async () => { |
| 28 | 28 | const raw = await page("/"); |
| 29 | 29 | const title = /<title>([^<]*)<\/title>/.exec(raw)?.[1] ?? ""; |
| 30 | expect(title).toStartWith("forge"); | |
| 31 | expect(title.match(/forge/g)).toHaveLength(1); | |
| 30 | expect(title).toStartWith("Open Platform Forge: "); | |
| 31 | expect(title.match(/forge/gi)).toHaveLength(1); | |
| 32 | 32 | expect(raw).toContain(`property="og:title" content="${title}"`); |
| 33 | 33 | }); |
| 34 | 34 | |
| @@ -44,6 +44,7 @@ test("no title ends in the suffix twice", async () => { | ||
| 44 | 44 | // A title that opens with the forge's name names it already. |
| 45 | 45 | expect(tabTitle("forge: software for what you're building")).toBe("forge: software for what you're building"); |
| 46 | 46 | expect(tabTitle("forge-x")).toBe("forge-x · forge"); |
| 47 | expect(tabTitle("Open Platform Forge: pricing")).toBe("Open Platform Forge: pricing"); | |
| 47 | 48 | for (const path of ["/search?q=forge", "/trevato/forge/issues", "/trevato/forge/pulls"]) |
| 48 | 49 | expect(await page(path)).not.toContain("· forge · forge"); |
| 49 | 50 | }); |
test/pricing-order.test.ts +1 −1
| @@ -80,7 +80,7 @@ describe("/pricing", () => { | ||
| 80 | 80 | expect(res.status).toBe(200); |
| 81 | 81 | const text = await res.text(); |
| 82 | 82 | expect(text).toContain("$19 a month"); |
| 83 | expect(text).toContain("Start free"); | |
| 83 | expect(text).toContain("Start my forge"); | |
| 84 | 84 | expect(res.headers.get("content-security-policy")).toBeTruthy(); |
| 85 | 85 | }); |
| 86 | 86 |
test/pull-quiet.test.ts +2 −1
| @@ -66,8 +66,9 @@ afterAll(async () => { | ||
| 66 | 66 | const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); |
| 67 | 67 | const bytes = (t: string) => new TextEncoder().encode(t).length; |
| 68 | 68 | |
| 69 | // Share tags with the full name (#11654): 31,983 pull, 25,359 list, budgets that plus ~170. | |
| 69 | 70 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. With the Your-dotfiles palette entry (#10548) the list measures 25,260, budget that plus ~170. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160. |
| 70 | const BUDGET = { pull: 32_030, list: 25_430 }; | |
| 71 | const BUDGET = { pull: 32_160, list: 25_530 }; | |
| 71 | 72 | |
| 72 | 73 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 73 | 74 | const text = await get(`/alice/r/pulls/${number}`); |
new above
offline