diff --git a/cloud/protocol.ts b/cloud/protocol.ts index e7c97d412..9279e33bd 100644 --- a/cloud/protocol.ts +++ b/cloud/protocol.ts @@ -881,11 +881,13 @@ export type SharedApp = { lost?: true; /** * Its production is on GitHub (lib/hosted-apps.ts AppHosting.github): Publish sends what its Preview serves to this repo - * and branch, whose own deploy puts it live. At Write also its folder, the paths left alone, the workflow it runs, and - * where it was last the same as GitHub (`linked`: Link, Publish or Bring in; none: it isn't linked yet), so an Admin, or - * a Member with App secrets, links it and brings GitHub's changes in (SharedWsSlice.appGithub). + * and branch, whose own deploy puts it live. `at`: the changeset last sent there, which is its Live (null: not linked + * yet), at every level (none from a Bops Cloud before it: Live is then read from `linked`, or from Published as before). + * At Write also its folder, the paths left alone, the workflow it runs, and where it was last the same as GitHub + * (`linked`: Link, Publish or Bring in; none: it isn't linked yet), so an Admin, or a Member with App secrets, links it + * and brings GitHub's changes in (SharedWsSlice.appGithub). */ - github?: { repo: string; branch: string; path?: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record }; linked?: { sha: string; ingot: string; when: number } }; + github?: { repo: string; branch: string; at?: string | null; path?: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record }; linked?: { sha: string; ingot: string; when: number } }; head?: { id: string; message: string; at: number } | null; /** * `url`: Published's address (none: no domain yet). Its Preview, where members deploy, from an owner's Bops that sends diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts index 1964769de..d0fb8a05a 100644 --- a/cloud/test/core-members.test.ts +++ b/cloud/test/core-members.test.ts @@ -1953,16 +1953,22 @@ test("Your Apps at Read, in a workspace of Orgo's team: the apps by name, their assert.deepEqual(s.workspace, { id: "ws_apps", name: "Apps" }, "the workspace's own fields stay as they were"); }); -test("Your Apps says where an app whose production is on GitHub publishes to (its repo and branch), never its folder, kept files or point; nothing for a malformed setting", () => { +test("Your Apps says where an app whose production is on GitHub publishes to (its repo and branch) and the changeset last sent there, never its folder, kept files or GitHub commit; nothing for a malformed setting", () => { const github = { repo: "acme/shop-site", branch: "main", path: "web", keep: [".env.example"], dispatch: { workflow: "deploy.yml", inputs: { client: "shop" } }, last: { sha: "abc123", ingot: "cs-2", when: 9 } }; const blob = appsBlob({ apps: "read" }); const ws = (blob.workspaces as Record>>>[])[0]; ws.hostedApps.shop.github = github; ws.hostedApps.notes.github = { repo: 7, branch: "main" }; const s = sharedSlice(blob, "ws_apps", TOM_VIEW); - assert.deepEqual(s.apps?.list[0].github, { repo: "acme/shop-site", branch: "main" }); + // (`at`: what its Live is, so a member's review compares the Preview with GitHub, not with Orgo's Published.) + assert.deepEqual(s.apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: "cs-2" }); assert.equal(s.apps?.list[1].github, undefined, "a malformed setting shows nothing"); for (const hidden of ["web", ".env.example", "deploy.yml", "abc123"]) assert.ok(!JSON.stringify(s.apps).includes(`"${hidden}"`), `"${hidden}" isn't shown`); + // Not linked yet (no `last`), or a `last` with no changeset: null, said as such. + ws.hostedApps.shop.github = { repo: "acme/shop-site", branch: "main", path: "" }; + assert.deepEqual(sharedSlice(blob, "ws_apps", TOM_VIEW).apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: null }); + ws.hostedApps.shop.github = { repo: "acme/shop-site", branch: "main", path: "", last: { sha: "abc123", when: 9 } }; + assert.deepEqual(sharedSlice(blob, "ws_apps", TOM_VIEW).apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: null }); }); test("Your Apps at Write adds each site's settings and the workspace's domains, whatever the member's email; nothing for None, an older entry, the owner, a workspace whose owner isn't Orgo's team, or no apps", () => { @@ -2419,17 +2425,18 @@ test("the slice: GitHub's folder, paths left alone, workflow and point at Write assert.deepEqual(site.github, { repo: "example-org/web", branch: "main", + at: "b".repeat(64), path: "formula", keep: ["proxy.ts"], dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } }, linked: { sha: "a".repeat(40), ingot: "b".repeat(64), when: 7 }, }); - assert.deepEqual(sam.apps!.list.find((a) => a.name === "blog")!.github, { repo: "example-org/web", branch: "main", path: "blog" }, "set, not linked"); + assert.deepEqual(sam.apps!.list.find((a) => a.name === "blog")!.github, { repo: "example-org/web", branch: "main", at: null, path: "blog" }, "set, not linked"); assert.equal(sam.appGithub, true); assert.deepEqual(site.acts?.map((a) => [a.act, a.going]), [["githubPull", true], ["deploy", undefined]]); - // At Read: where Publish goes, nothing more, and no GitHub buttons. + // At Read: where Publish goes and what its Live is (the changeset last sent there), nothing more, and no GitHub buttons. const rob = s(ghViewer("member", "rob@example.com", { githubActs: true, githubSeen: true })); - assert.deepEqual(rob.apps!.list.find((a) => a.name === "site")!.github, { repo: "example-org/web", branch: "main" }); + assert.deepEqual(rob.apps!.list.find((a) => a.name === "site")!.github, { repo: "example-org/web", branch: "main", at: "b".repeat(64) }); assert.equal(rob.appGithub, undefined); // A Member at Write without App secrets, or anyone while the owner's last Mac is older: none. assert.equal(s(ghViewer("member", "ivy@example.com", { githubActs: true })).appGithub, undefined); diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts index 4de97c378..fa2e8d0ae 100644 --- a/cloud/ws-slice.ts +++ b/cloud/ws-slice.ts @@ -434,21 +434,24 @@ const GITHUB_ACT = new Set(["github", "githubLink", "githubPull"]); const SETTINGS_SEEN: Spec = { port: true, install: true, build: true, start: true, health: true }; /** * An app's GitHub production as shown (repo and branch, as the owner's Bops or Bops Cloud checked them when set), or none - * when malformed. At Write (`write`) also its folder, the paths left alone, the workflow it runs and where it was last the - * same as GitHub (`linked`, of its point: never more than its commits' ids and when), so Your Apps links it and brings - * GitHub's changes in. + * when malformed; at every level `at`, the changeset last sent there (github.last.ingot: a Link, a Publish or a Bring in, + * by the owner's Mac or a runner), which is what its Live is, or null when it isn't linked yet. At Write (`write`) also its + * folder, the paths left alone, the workflow it runs and where it was last the same as GitHub (`linked`, of its point: + * never more than its commits' ids and when), so Your Apps links it and brings GitHub's changes in. */ const githubOf = (g: unknown, write = false): SharedApp["github"] | undefined => { if (!isObject(g) || typeof g.repo !== "string" || typeof g.branch !== "string" || g.repo.length > 201 || g.branch.length > 200) return undefined; - if (!write) return { repo: g.repo, branch: g.branch }; const words = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max; + const last = isObject(g.last) && words(g.last.sha, 64) && words(g.last.ingot, 64) && typeof g.last.when === "number" ? g.last : undefined; + const at = last ? (last.ingot as string) : null; + if (!write) return { repo: g.repo, branch: g.branch, at }; const keep = Array.isArray(g.keep) ? g.keep.filter((k): k is string => words(k, 300)).slice(0, 50) : []; const d = isObject(g.dispatch) && words(g.dispatch.workflow, 120) ? g.dispatch : undefined; const inputs = d && isObject(d.inputs) ? Object.fromEntries(Object.entries(d.inputs).filter((e): e is [string, string] => e[0].length <= 50 && words(e[1], 200)).slice(0, 10)) : undefined; - const last = isObject(g.last) && words(g.last.sha, 64) && words(g.last.ingot, 64) && typeof g.last.when === "number" ? g.last : undefined; return { repo: g.repo, branch: g.branch, + at, ...(words(g.path, 300) ? { path: g.path } : {}), ...(keep.length ? { keep } : {}), ...(d ? { dispatch: { workflow: d.workflow as string, ...(inputs && Object.keys(inputs).length ? { inputs } : {}) } } : {}), diff --git a/components/app/app-controls.tsx b/components/app/app-controls.tsx new file mode 100644 index 000000000..42b497964 --- /dev/null +++ b/components/app/app-controls.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Fold } from "./fold"; + +// Recipes shared with CRM. Keep the app's existing type, radius, and material. +export const appIcon = { size: 16, strokeWidth: 1.5, "aria-hidden": true as const }; +const appAction = "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] leading-4 text-ink disabled:text-pencil"; +export const appOutline = `${appAction} font-medium shadow-[0_0_0_1px_#E6E6E3] hover:bg-desk`; +export const appAttention = `${appAction} bg-highlighter font-semibold hover:bg-highlighter/80 disabled:bg-desk`; +export const appPrimary = "inline-flex shrink-0 items-center justify-center rounded-full bg-ink px-3.5 py-1.5 text-[12.5px] font-medium leading-4 text-paper disabled:bg-desk disabled:text-pencil"; +export const appGhost = "inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] font-medium leading-4 text-pencil hover:bg-desk hover:text-ink disabled:opacity-50"; +export const appList = "flex flex-col overflow-hidden rounded-2xl shadow-[0_0_0_1px_#ECECEA]"; +export const appRow = "flex items-center gap-3 border-b border-rule px-3.5 py-3 hover:bg-desk"; +const appTagBase = "shrink-0 rounded-full px-2.5 py-1 text-[12px] leading-4 text-ink"; +export const appTag = `${appTagBase} bg-desk font-medium`; +export const appAttentionTag = `${appTagBase} bg-highlighter font-semibold`; +export const segmented = "flex shrink-0 gap-0.5 rounded-full bg-black/[0.05] p-[3px]"; +export const segment = (on: boolean, blocked = false) => `rounded-full px-2.5 py-1 text-[12px] leading-4 ${on ? "bg-paper text-ink shadow-[0_0_0_1px_#0000000F]" : blocked ? "cursor-default text-[#C9C9C6]" : "text-pencil hover:text-ink"}`; + +export function AppDisclosure({ title, children, initiallyOpen = false }: { title: string; children: ReactNode; initiallyOpen?: boolean }) { + const [open, setOpen] = useState(initiallyOpen); + return
+ + {children} +
; +} diff --git a/components/app/app-envs.tsx b/components/app/app-envs.tsx index 2701af97e..87d8fe14f 100644 --- a/components/app/app-envs.tsx +++ b/components/app/app-envs.tsx @@ -1,12 +1,17 @@ "use client"; +import { MoreHorizontal } from "lucide-react"; +import { statusForApp } from "@/lib/app-review"; +import type { AppCommit } from "@/lib/hosted-apps"; +import { AppPassword, AppReview } from "./app-review"; +import { appIcon } from "./app-controls"; import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { allLocals, localWords, type AppDeploy, type AppEnv, type HostedApp, type HostedAppsAnswer, type HostedSite, type LocalAnswer, type LocalSite, type PreviewSite, type SiteDomain } from "@/lib/hosted-apps"; import { readSettings, Variables } from "./app-settings"; -import { act, deploying, isSending, OpenHostedApp, PROBLEM, PULSE, short, UrlLink, useSending } from "./hosted-apps"; +import { AppEnvCtx, act, deploying, isSending, OpenHostedApp, PROBLEM, PULSE, short, UrlLink, useSending } from "./hosted-apps"; import { useDismiss } from "./new-thread"; import { OpenPreview } from "./panel-tabs"; -import { PasswordBeside } from "./preview-password"; +import { PasswordBeside, usePreviewPassword } from "./preview-password"; import { GithubForm, ghFormOf, ghMirrorOf, NO_GH_FORM, type GhForm } from "./github-form"; import { InlineConfirm, undash } from "./side-group"; import { post } from "./ui"; @@ -161,7 +166,7 @@ export function whyNotEnv(answer: Pick, a: const gh = a.hosting?.github; if (gh) { if (!a.preview?.serving) return "The preview isn't live yet. Deploy a preview first."; - if (!gh.last) return "Link it to GitHub first, in the menu beside Publish."; + if (!gh.last) return "Link it to GitHub first: open Technical details, then the menu on the Published line."; // (Whether GitHub has this preview already is the server's to say: Publish then sends nothing.) return undefined; } @@ -196,7 +201,13 @@ export function AppEnvs({ onPick, onDomain, compact, + commits = [], + history, + historyOpen = false, }: { + historyOpen?: boolean; + commits?: AppCommit[]; + history?: ReactNode; ws: string; answer: Pick; app: HostedApp; @@ -234,10 +245,10 @@ export function AppEnvs({ if (r.app) onChanged(r.app); if (r.error) setError(undash(r.error)); if (r.said) setSaid(undash(r.said)); + return !r.error; }; const deploy = () => run("preview", () => act(app.name, { ws, action: "deploy", env: "preview", ...(site ? { site } : {}) })); - const promote = () => { - const at = app.preview?.serving; + const promote = (at = app.preview?.serving) => { return run("promote", () => act(app.name, site && at ? { ws, action: "promote", site, at } : !many && at ? { ws, action: "promote", site: app.name, at } : { ws, action: "promote" })); }; /** Apply variables: Published's own changeset built again, with its variables as they are now. */ @@ -304,7 +315,7 @@ export function AppEnvs({ return run("github", async () => { const r = await act(app.name, { ws, action: "github", github: v ? ghMirrorOf(v) : null }); if (r.app) setGhForm(null); - return r.app ? { ...r, said: v ? "Saved. Link it to GitHub next, in the menu beside Publish." : "Publish deploys on Orgo again." } : r; + return r.app ? { ...r, said: v ? "Saved. Link it to GitHub next, from the menu on the Published line in Technical details." : "Publish deploys on Orgo again." } : r; }); }; // GitHub's changes into Ingot, GitHub's version winning where both changed a file (what Publish's conflict says to do). @@ -364,7 +375,7 @@ export function AppEnvs({ const prodState: EnvState = gh ? gh.last ? { word: "On GitHub", dot: DOTS.on, tip: `Publish pushes what the Preview serves to ${gh.repo} ${gh.branch}, where its own workflow deploys it.` } - : { word: "Not linked", dot: DOTS.off, tip: "Link it to GitHub in the menu beside Publish." } + : { word: "Not linked", dot: DOTS.off, tip: "Link it to GitHub from the menu on this line." } : lost ?? (noDomain && neverLive ? { word: "Not published", dot: DOTS.off } : (() => { const st = many && !site ? allSites(sites) : deployState(app); return st.word === "Not deployed yet" ? { ...st, word: "Not published" } : st; @@ -413,27 +424,22 @@ export function AppEnvs({ const k = bot ? `:${bot}` : ""; return up ? ( <> - void preview(bot)} /> + void preview(bot)} /> void localDo("stop", bot)} /> ) : ( void localDo("start", bot)} /> ); } - if (env === "preview") return void deploy()} />; + if (env === "preview") return void deploy()} />; // No domain and never live: connecting one comes first (Published lives on its domain only). if (!gh && !lost && noDomain && neverLive && answer.enabled) return ; return ( <> - void promote()} />
e.stopPropagation()}> {menu && gh && (
@@ -475,7 +481,7 @@ export function AppEnvs({ }; // Lines named for their bots (more bots work on it): a wider column for the names, so the states line up. const named = lines.some((l) => l.who); - return ( + const advanced = ( // (Its lines read the width they have, not the window's: the Apps page's, or its tab's in a panel. Its menu open, it's over the lines after it.)
{!compact && Local, Preview and Published} @@ -494,7 +500,7 @@ export function AppEnvs({ > {/* Its place: its dot, its name, and whose it is (Local · Nova). Wide, a column of its own; narrow, what's left of the first line. */} - + {onPick ? (
); + const pass = usePreviewPassword(ws, app.name, site, app.preview); + const status = statusForApp(app, commits); + const publishTo = domain ?? pending?.domain ?? app.hosting?.url ?? "No domain yet"; + const liveAt = gh ? gh.last?.ingot ?? null : app.serving; + return <> x.domains?.some(d => d.status === "active")) ? "Publishes this reviewed version on Orgo. Visitors will see it once the domain points here." : undefined} + destination={gh ? `${gh.repo} · ${gh.branch}${gh.path ? ` · ${gh.path}/` : ""}` : publishTo} + env={picked === "prod" ? "prod" : "preview"} onEnv={onPick} busy={!!busy} + password={pass.on ? : undefined} + whyUpdate={whyNotEnv(answer, app, "preview", sites)} whyPublish={status.action === "connect-domain" ? undefined : whyNotEnv(answer, app, "prod", sites)} + onUpdate={() => void deploy()} onPublish={at => promote(at)} onDomain={domainFlow} history={history} historyOpen={historyOpen} + technical={
+
Preview URL
{app.preview?.url ?? "No preview yet"}
Version
{app.preview?.serving ?? "None"}
Publish to
{gh ? ghWhere : publishTo}
+

Local is the bot's working copy. Update preview creates the separate copy you review.

+ {advanced} + +
}/>{error && {error}}{said && {said}}; } const HEAD = "pb-1 text-[12px] font-semibold leading-4 text-[#6B6B6B]"; @@ -640,7 +663,6 @@ export function LocalSection({ ws, app, site, local, onLocal }: { ws: string; ap Local {`Runs its working folder with hot reload, at ${at ? `localhost:${at}` : "a localhost address of its own"} here and on its computer.`}
- {s.word} diff --git a/components/app/app-review.tsx b/components/app/app-review.tsx new file mode 100644 index 000000000..039beb504 --- /dev/null +++ b/components/app/app-review.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef, useState, type ReactNode } from "react"; +import { ArrowUpRight, Check, Copy, Info, Lock } from "lucide-react"; +import type { AppCommit } from "@/lib/hosted-apps"; +import { hasUnpublishedPreview, reviewLabel, type ReviewStatus } from "@/lib/app-review"; +import { AppDisclosure, appAttention, appAttentionTag, appGhost, appIcon, appList, appOutline, appPrimary, appRow, appTag, segment, segmented } from "./app-controls"; +import { Fold } from "./fold"; +import { useDismiss } from "./new-thread"; +import { ago, AskInPlace } from "./ui"; + +/** Revealed only on request; clipboard failures keep the value available for manual copying. */ +export function AppPassword({ password, waiting, error }: { password: string | null; waiting?: boolean; error?: string | null }) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [copyError, setCopyError] = useState(false); + const box = useRef(null); + const button = useRef(null); + useDismiss(open, () => { setOpen(false); button.current?.focus(); }, box); + return
+ + {open &&
+ {password ? <>{password}{copyError && Couldn't copy. Select the password to copy it.} : {waiting ? "Reading password…" : error ?? "Ask the owner for the password."}} +
} +
; +} + +type Version = Pick & Partial>; +/** `allSites`: Restore puts the version on every site's Preview (an app of more than one site), and says so. */ +export function AppVersions({ versions, preview, published, busy, onRestore, whyRestore, allSites = false, children }: { versions: Version[]; preview: string | null; published: string | null; busy?: boolean; onRestore?: (id: string) => void; whyRestore?: (id: string) => string | undefined; allSites?: boolean; children?: ReactNode }) { + const restore = allSites ? "Restore to preview, all sites" : "Restore to preview"; + return
+ {versions.map(v =>
+ {v.message.split("\n")[0] || "No message"} + {v.id === preview ? "Preview" : v.id === published ? "Published" : ""} + {v.author} + {ago(v.at)} + {onRestore && } +
)} + {children} +
; +} + +/** + * Same review decisions for the owner and members; callers retain their existing request contracts. `updateAlso`: Update + * preview stays beside Publish (an Admin's page, which has no Technical details line of its own to update it from), so + * whoever may publish may also rebuild the Preview. "Add a domain" shows only with `onDomain`: without it (a member), + * Update preview takes its place, or nothing. + */ +export function AppReview({ status, previewUrl, liveUrl, previewAt, publishedAt, versions = [], password, github, destination, busy, whyUpdate, whyPublish, onUpdate, onPublish, onDomain, technical, history, env = "preview", onEnv, publishLine, historyOpen = false, updateAlso = false }: { + historyOpen?: boolean; publishLine?: string; status: ReviewStatus; previewUrl: string | null; liveUrl: string | null; previewAt: string | null; publishedAt: string | null; + versions?: Version[]; password?: ReactNode; github?: boolean; destination: string; busy?: boolean; whyUpdate?: string; whyPublish?: string; + onUpdate?: () => void; onPublish?: (at: string) => Promise; onDomain?: () => void; + technical: ReactNode; history?: ReactNode; env?: "preview" | "prod"; onEnv?: (env: "preview" | "prod") => void; updateAlso?: boolean; +}) { + const [help, setHelp] = useState(false); + const [confirm, setConfirm] = useState<{ at: string; note: string; destination: string } | null>(null); + const [sending, setSending] = useState(false); + const shown = env === "preview" ? previewAt : publishedAt; + const change = versions.find(v => v.id === shown); + // (A domain to add with no way to add one here: Update preview, the one thing that can still be pressed.) + const noDomainHere = status.action === "connect-domain" && !onDomain; + const update = status.action === "update-preview" || (!!onUpdate && (!onPublish || noDomainHere)); + const domain = status.action === "connect-domain" && !update; + const label = update ? "Update preview" : domain ? "Add a domain" : "Publish changes"; + const why = update ? whyUpdate : whyPublish; + const primary = update ? !!onUpdate : domain ? !!onDomain : !!onPublish; + const updateBeside = updateAlso && !update && !!onUpdate; + const url = env === "preview" ? previewUrl : liveUrl; + const moved = !!confirm && (confirm.at !== previewAt || confirm.destination !== destination); + const unpublished = hasUnpublishedPreview(status); + return
+
+
{(["preview", "prod"] as const).map(p => )}
+ + {reviewLabel(status)} +
+

Preview is your review copy. Review it before starting a different change; keep editing if you’re improving this one.

Live {github ? "shows the version last sent to GitHub. Your website updates through its connected deployment." : "is the website visitors use."}

+
+
+ {url ? {env === "preview" ? unpublished ? "Review preview" : "Open preview" : "Open website"} : {env === "preview" ? "No preview yet" : github ? "Website address not available" : "Not live yet"}} + {env === "preview" && password} + {env === "preview" && updateBeside && } + {env === "preview" && primary && } +
+
{change?.message.split("\n")[0] ?? (shown ? "Version details unavailable" : "No version here yet")}{env === "preview" ? "Preview" : github ? "Sent to GitHub" : "Last published"}{change && {ago(change.at)}}
+
+ {confirm &&
setConfirm(null)} onConfirm={async () => { if (moved || !onPublish) return; setSending(true); try { if (await onPublish(confirm.at)) setConfirm(null); } finally { setSending(false); } }}>
{confirm.note}{confirm.destination}{moved && The preview or destination changed. Keep reviewing to check it again.}
} + {history && {history}} + {technical} +
; +} diff --git a/components/app/app-settings.tsx b/components/app/app-settings.tsx index 8e4fa7f4d..bd51b32e1 100644 --- a/components/app/app-settings.tsx +++ b/components/app/app-settings.tsx @@ -1,5 +1,7 @@ "use client"; +import { Pencil, Plus, Trash2, X } from "lucide-react"; +import { appIcon } from "./app-controls"; import { useContext, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { ENV_NAME, ENV_RESERVED, isAppName, isSiteName, MAX_SITES, offersInOrder, REGISTRANT_FIELDS, subdomainTyped, typedAppName } from "@/lib/hosted-apps"; import type { @@ -33,7 +35,7 @@ import type { WorkspaceDomainsAnswer, WorkspaceZoneAnswer, } from "@/lib/hosted-apps"; -import { PencilIcon, rowIconBtn, rowTrashBtn, TrashIcon } from "./apps"; +import { rowIconBtn, rowTrashBtn } from "./apps"; import { act, AppEnvCtx, AppSite, CopyLink, envQuery, isSending, PILL, QUIET, removeApp, short, siteQuery, STATES, UrlLink, useSending } from "./hosted-apps"; import { InlineConfirm, undash } from "./side-group"; import { ago, post } from "./ui"; @@ -889,9 +891,7 @@ export function AddDomain({ ); const closeButton = ( ); // A Published site's: Its domain, From Cloudflare first, then Use a domain I own (a small link to its own form). @@ -1000,9 +1000,7 @@ export function AddDomain({ {tab("own", "Use a domain I own")}
{body} @@ -1534,7 +1532,7 @@ export function Variables({ ws, name, names, onSaved, first, lacks }: { ws: stri }} className={rowIconBtn} > - +
@@ -1583,9 +1581,7 @@ export function Variables({ ws, name, names, onSaved, first, lacks }: { ws: stri disabled={busy} className="-mx-2 mt-0.5 flex h-7 items-center gap-1.5 self-start rounded-lg px-2 text-[12px] leading-4 text-[#6B6B6B] hover:bg-[#F6F6F4] hover:text-ink disabled:text-[#9A9A98]" > - - - + Add variable )} diff --git a/components/app/bops-app.tsx b/components/app/bops-app.tsx index 7457759be..ad3ae5b4d 100644 --- a/components/app/bops-app.tsx +++ b/components/app/bops-app.tsx @@ -863,22 +863,22 @@ function Bops({ state }: { state: AppState | null }) { openComputer(fresh.botId); } }; - const openChat = (id: string) => { + const openChat = (id: string, stay = false) => { setChatId(id); setPrefill(null); setNewThreadFor(null); setThreadId(null); setComposing(false); setFocus(false); - setActive(CHAT_TAB); + if (!stay) setActive(CHAT_TAB); }; /** * Ask a bot something (an example on Routines, or Ask ): its chat, with `text` in the message box * to change and send, never sent by itself, and the box focused. */ - const askBot = (botId: string, text?: string) => { + const askBot = (botId: string, text?: string, stay = false) => { const id = botChatId(botId); - openChat(id); + openChat(id, stay); if (state.chats.some((c) => c.id === id)) setPrefill({ chatId: id, text: text ?? "", at: Date.now() }); }; /** The task Add app started (hosted-apps.tsx): its thread once the window has it, else its bot's chat, where its chip is. */ @@ -1294,7 +1294,7 @@ function Bops({ state }: { state: AppState | null }) { ) : tab?.kind === "made-apps" ? ( ) : tab?.kind === "hosted-app" ? ( - + askBot(id, text, true)} /> ) : tab?.kind === "crm" ? ( // These two pages fade in from here (the others in their own files). The key is on the // wrapper, so the page remounts exactly when it did before. diff --git a/components/app/crm.tsx b/components/app/crm.tsx index 7c6c20475..3ecede184 100644 --- a/components/app/crm.tsx +++ b/components/app/crm.tsx @@ -1,5 +1,7 @@ "use client"; +import { segmented, segment } from "./app-controls"; + import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -1035,7 +1037,7 @@ export function CrmView({ ))} -
+
{(["bar", "line"] as const).map((k) => { const on = (k === "line") === lineWanted; const blocked = k === "line" && typeOf(by) !== "date"; @@ -1046,7 +1048,7 @@ export function CrmView({ aria-disabled={blocked || undefined} data-tip={blocked ? "Line needs a date column, like Close date." : undefined} onClick={() => !blocked && setView({ ...view, kind: k })} - className={`rounded-full px-2.5 py-1 text-[12px] leading-4 ${on ? "bg-white text-ink shadow-[0_0_0_1px_#0000000F]" : blocked ? "cursor-default text-[#C9C9C6]" : "text-[#6B6B6B] hover:text-ink"}`} + className={segment(on, blocked)} > {k === "bar" ? "Bar" : "Line"} diff --git a/components/app/hosted-apps.tsx b/components/app/hosted-apps.tsx index 1ee56d290..897aae7be 100644 --- a/components/app/hosted-apps.tsx +++ b/components/app/hosted-apps.tsx @@ -1,11 +1,13 @@ "use client"; -import { createContext, Fragment, useContext, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { Children, createContext, useContext, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { flushSync } from "react-dom"; +import { ArrowLeft, Check, ChevronDown, ChevronRight, Copy, Globe, Lock, Pencil, Plus, Trash2 } from "lucide-react"; +import { AppDisclosure, appAttention, appGhost, appIcon, appList, appOutline, appRow, segmented, segment } from "./app-controls"; +import { hasUnpublishedPreview, reviewLabel, statusForApp } from "@/lib/app-review"; import { DNS_TYPES, DOMAIN_WORDS, dnsNeeds } from "@/lib/hosted-apps"; import type { AddAppAnswer, - AppCommit, AppCommitsAnswer, AppDnsInput, AppDnsRecord, @@ -47,9 +49,10 @@ import { setAutoRenew, type RenewalFlip, } from "./app-settings"; -import { PencilIcon, rowIconBtn, rowTrashBtn, TrashIcon } from "./apps"; +import { rowIconBtn, rowTrashBtn } from "./apps"; import { InlineConfirm, undash } from "./side-group"; -import { ago, post } from "./ui"; +import { AppVersions } from "./app-review"; +import { ago, post, RoundButton } from "./ui"; import { AppEnvs, LocalSection, openIntent, useLocal } from "./app-envs"; import { PasswordSection } from "./preview-password"; import { actLine } from "./shared-apps"; @@ -535,14 +538,9 @@ export function CopyLink({ url, label = "Copy link" }: { url: string; label?: st className="flex size-6 shrink-0 items-center justify-center rounded-md text-[#6B6B6B] hover:bg-black/[0.05] hover:text-ink" > {copied ? ( - - - + ) : ( - - - - + )} ); @@ -558,28 +556,27 @@ export const PROBLEM = "truncate text-[12px] leading-4 text-[#B42318]"; * The Apps page's "On the web" list (made-apps.tsx MadeAppsHome), read from the server (again when this * Mac queues a deploy: appsStamp). */ -export function HostedApps({ state, ws }: { state?: Pick | null; ws: string }) { +export function HostedApps({ state, ws, tools }: { state?: Pick | null; ws: string; tools?: ReactNode }) { const { answer, failed, put } = useHostedApps(ws, appsStamp(state, ws)); const started = useContext(OpenAppTask); const builder = state?.bots?.find((b) => b.isMain && workspaceOf(b) === ws)?.name ?? null; // What people with Your Apps did to each app, newest first (lib/server/apps-for-members.ts): its row says the newest. const seen = state?.workspaces?.find((w) => w.id === ws)?.appsForMembers?.apps; - return seen?.[name]?.acts?.[0]} />; + return seen?.[name]?.acts?.[0]} />; } /** - * One line per app: its state, name, public address (opened in the browser), the short id of the changeset - * its site serves, and Deploy (the newest changeset). The rest of the line opens its tab. Nothing until - * it's read, one quiet line when there are none, and nothing at all when there are none and deploying - * isn't open here (no invitation to make a site that can't go on the web). Add app, in its title (and - * beside the line that says there are none): a form, and the workspace's main bot (`builder`) builds it; - * `onStarted` opens that task. + * One line per app: its name, public address (opened in the browser), where its review stands, and Review changes + * or Open app (its tab). Nothing until it's read and one quiet line when there are none; when there are none and + * deploying isn't open here, only why (no invitation to make a site that can't go on the web), with the tools still + * listed. Add app, in its title (and beside the line that says there are none): a form, and the workspace's main + * bot (`builder`) builds it; `onStarted` opens that task. */ export function HostedAppsList({ ws, answer, failed, - onChanged, + tools, builder, onStarted, actOf, @@ -588,6 +585,7 @@ export function HostedAppsList({ answer: HostedAppsAnswer | null; failed?: boolean; onChanged: (app: HostedApp) => void; + tools?: ReactNode; builder?: string | null; onStarted?: (a: AddAppAnswer) => void; /** The newest thing someone the workspace is shared with did to an app (Your Apps at Write), said under its row. */ @@ -595,7 +593,7 @@ export function HostedAppsList({ }) { const open = useContext(OpenHostedApp); const [adding, setAdding] = useState(false); - if (answer && !answer.enabled && !answer.apps.length) return null; + const hasTools = Children.toArray(tools).length > 0; const why = answer && !answer.enabled ? answer.why || "Apps can't go on the web from here." : undefined; const addButton = answer && !adding && ( @@ -605,17 +603,15 @@ export function HostedAppsList({ onClick={() => setAdding(true)} className="flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-[12px] font-medium leading-4 text-ink shadow-[0_0_0_1px_#E2E2DF] hover:bg-[#F6F6F4] disabled:text-[#9A9A98] disabled:shadow-[0_0_0_1px_#ECECEA]" > - - - + Add app ); return ( -
+
- On the web +

Your Apps

{!!answer?.apps.length && addButton}
@@ -633,68 +629,38 @@ export function HostedAppsList({ {!answer ? ( failed && Couldn't read them just now. ) : !answer.apps.length ? ( - !adding && ( + !adding && + (answer.enabled ? (
Ask a bot to make a website, or add one here. {addButton}
- ) + ) : ( + {why} + )) ) : ( -
+
{answer.apps.map((a) => { - const url = urlOf(a); + const status = statusForApp(a); + const label = a.hosting?.lost ? "Needs a new home" : a.preview?.serving && !a.serving && !a.hosting?.github ? "Not live" : reviewLabel(status); const theirs = actOf?.(a.name); - return ( - -
open?.(ws, a.name)} - className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-[#F6F6F4]" - > - - {/* The row's button for the keyboard: its press comes up to the row. */} - - {a.hosting?.lost ? ( - - Needs a new home - - ) : url ? ( - - ) : ( - {noAddress(a)} - )} - {a.sites && ( - x.name).join(", ")} className="shrink-0 text-[12px] leading-4 text-[#9A9A98]"> - {a.sites.length} sites - - )} - -
- {/* Its three environments, all on its computer: Local, Preview (where Deploy goes), Published (Publish). */} - - {theirs && ( - - {actLine(theirs)} - - )} -
- ); + return
+ + {label} + +
; })} + {tools}
)} + {(!answer || !answer.apps.length) && hasTools &&
{tools}
}
); } -/** An app's three lines on the Apps page (AppEnvs), its Local read from its computer (never one gone). */ -function ListedEnvs({ ws, answer, app, onChanged }: { ws: string; answer: Pick; app: HostedApp; onChanged: (app: HostedApp) => void }) { - const local = useLocal(ws, app.name, !app.hosting?.lost); - return ; -} - /** * An app's tab: read with the list (so it shows at once from the Apps page, and again when this Mac queues * a deploy: appsStamp), and its history. `onAsk`: a bot's chat with words typed in (bops-app.tsx askBot), @@ -705,7 +671,9 @@ export function HostedAppTab({ ws, name, onAsk, + onBack, }: { + onBack?: () => void; state?: (Pick & Partial>) | null; ws: string; name: string; @@ -715,7 +683,7 @@ export function HostedAppTab({ const [picked, setPicked] = useState(undefined); // Which of its environments the sections are about (Prod by default), and Add a domain opened from Domain in Prod's menu. const [intent] = useState(() => openIntent(name)); - const [env, setEnv] = useState("prod"); + const [env, setEnv] = useState(intent === "domain" ? "prod" : "preview"); const local = useLocal(ws, name); // (Read again when another site is picked too: the lines of the others then say what Orgo says now, a // domain the one left was checking gone live, say.) @@ -753,6 +721,7 @@ export function HostedAppTab({ const ask = onAsk && workOn(state, ws, app); return ( message.split("\n", 1)[0].trim(); /** * An app: its state, name, address (open, copy), "Work on it" (`work`: its bot's chat with words typed in, @@ -948,6 +916,7 @@ const firstLine = (message: string) => message.split("\n", 1)[0].trim(); * changeset Live on the sites that serve it. One whose computer is gone says it needs a new home. */ export function HostedAppView({ + onBack, ws, answer, app, @@ -976,6 +945,7 @@ export function HostedAppView({ addDomain = false, atHistory = false, }: { + onBack?: () => void; ws: string; answer: Pick; app: HostedApp; @@ -1018,19 +988,10 @@ export function HostedAppView({ atHistory?: boolean; }) { const { busy, error, run } = useDeploy(onChanged); - // Opened at its History: scrolled to once there's a history to show (a page read later doesn't move it again). - const historyBox = useRef(null); - const historyShown = useRef(!atHistory); - const historyRead = history !== null; - useEffect(() => { - if (historyShown.current || !historyRead || !historyBox.current) return; - historyShown.current = true; - historyBox.current.scrollIntoView?.({ block: "start", behavior: "smooth" }); - }, [historyRead]); - const [envMine, setEnvMine] = useState(addDomain ? "prod" : "prod"); - const env = onEnv ? (picked ?? "prod") : envMine; + const [settingsOpen, setSettingsOpen] = useState(addDomain); + const [envMine, setEnvMine] = useState(addDomain ? "prod" : "preview"); + const env = onEnv ? (picked ?? "preview") : envMine; const pickEnv = (x: AppEnv) => (onEnv ? onEnv(x) : setEnvMine(x)); - // Add a domain opened from Prod's menu here (or the Apps page's): Prod picked, its Domains' Add a domain open. const [adding, setAdding] = useState(addDomain ? 1 : 0); const [mine, setMine] = useState(undefined); const sites = app.sites; @@ -1040,88 +1001,31 @@ export function HostedAppView({ const siteName = current && current.name !== app.name ? current.name : undefined; const view = siteView(app, current); const lost = !!app.hosting?.lost; - const url = urlOf(app); const online = history && !history.offline ? history : null; - // What its site serves, as the list read last says (it's read again while a deploy runs), else as the history does. - const serving = app.serving ?? (sites ? null : online?.commits.find((c) => c.serving)?.id) ?? null; - /** The sites that serve a changeset, of one with more than one. */ - const servedBy = (id: string) => sites?.filter((x) => x.serving === id).map((x) => x.name) ?? []; - // What a deploy waiting or running builds (the newest, when it doesn't say): any site's. - const going = new Set( - [app, ...(sites ?? [])] - .flatMap((x) => [x, ...(x.preview ? [x.preview] : [])]) - .filter(deploying) - .map((x) => x.live?.at ?? online?.head ?? app.head), - ); - /** Where a changeset is on the Preview: the sites whose Preview serves it (the app, for one with one site). */ - const previewSites = (id: string) => (sites ? sites.filter((x) => x.preview?.serving === id).map((x) => x.name) : app.preview?.serving === id ? [app.name] : []); - /** The end of a changeset's line: Live (its site serves it; the ones that do, of more), Deploying, else "Deploy this one". */ - const end = (c: AppCommit) => { - const on = sites ? servedBy(c.id) : c.id === serving ? [app.name] : []; - const st = previewSites(c.id); - const chip = st.length ? ( - - - Preview - - ) : null; - if (on.length) - return ( - - {chip} - - - {sites && on.length < sites.length ? `Published: ${on.join(", ")}` : "Published"} - - - ); - if (chip && !going.has(c.id)) return chip; - if (going.has(c.id)) - return ( - - - Deploying - - ); - return ( - // Shown as its line is pointed at or reached with the keyboard, so the history reads as a list. - - void run(c.id, app.name, { ws, action: "deploy", env: "preview", at: c.id })} /> - - ); - }; + const gh = app.hosting?.github; + const published = gh ? gh.last?.ingot ?? null : view.serving ?? (!sites ? online?.commits.find(c => c.serving)?.id ?? null : null); + const historyBox = useRef(null); + const historyShown = useRef(false); + const historyReady = history !== null; + useEffect(() => { + if (!atHistory || !historyReady || historyShown.current || !historyBox.current) return; + historyShown.current = true; + historyBox.current.scrollIntoView?.({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + }, [atHistory, historyReady]); + const versions =
+ {history === null ? Reading history… : history === false ? Couldn't read the history just now. : history.offline ? History is unavailable while its computer is offline. : !history.commits.length ? No versions yet. : whyNot(answer, app, id, undefined, "preview")} allSites={!!sites?.length} onRestore={id => void run(id, app.name, { ws, action: "deploy", env: "preview", at: id })}> + {history.next && older && } + } +
; return (
-
- - - {app.name} - - {url ? ( - <> - - - - ) : ( - {lost ? "Needs a new home" : noAddress(app)} - )} - - {work && ( - - )} - void run("newest", app.name, { ws, action: "deploy", env: "preview" })} /> -
-
+
+
+ {onBack && } +

{app.name}{settingsOpen && / Settings}

+ {work && } + +
{lost && ( {lostTip(app)} @@ -1129,7 +1033,7 @@ export function HostedAppView({ )} {sites && current && (
- Sites +
Sites void run("newest", app.name, { ws, action: "deploy", env: "preview" })}/>
{sites.map((x) => ( )} {/* Its three environments, all on its computer (the picked site's): a line pressed picks what the sections under it are about. */} - { pickEnv("prod"); setAdding((n) => n + 1); + setSettingsOpen(true); }} - /> + />} {/* Made again for each site and environment picked: what was typed for one (its settings, a variable, a domain) never follows to another. */} + {settingsOpen &&
+ + 0}> +
+
{(["preview", "prod", "local"] as const).map(x => )}
{env === "prod" && ( <> @@ -1197,7 +1110,7 @@ export function HostedAppView({ {env === "preview" && ( - + {view.leftOver?.length ? : null} {previewSettings && (previewSettings.settings || (!lost && answer.enabled)) && } )} -
- History - {history === null ? ( - Reading it… - ) : history === false ? ( - Couldn't read the history just now. - ) : history.offline ? ( - - Ingot isn't answering just now, so the history isn't here. - - ) : !history.commits.length ? ( - No commits yet. - ) : ( - <> -
- {history.commits.map((c) => ( - 0 : c.id === serving} end={end(c)} /> - ))} -
- {history.next && older && ( - - )} - - )} -
- +
+
+ +
} +
); @@ -1298,55 +1183,6 @@ function SiteLine({ site: x, picked, lost, onPick, deploy }: { site: HostedSite; ); } -/** - * One changeset: its short id (all of it in the tooltip), its message's first line (all of it in the - * tooltip), who and when, how many files it added, changed and deleted, and how its line ends (`end`). - */ -function CommitLine({ commit: c, serving, end }: { commit: AppCommit; serving: boolean; end: ReactNode }) { - const message = firstLine(c.message); - return ( -
- - {short(c.id)} - - - {message || "No message"} - - - {c.author} - - - {ago(c.at)} - - - {end} -
- ); -} - -/** Files added, changed and deleted, as +2 ~1 -1 (the ones with none left out), in words in the tooltip. */ -function Changes({ changes }: { changes?: AppCommit["changes"] | null }) { - const { added = 0, modified = 0, deleted = 0 } = changes ?? {}; - const parts = [ - { n: added, mark: "+", word: "added", look: "text-[#1E7A4C]" }, - { n: modified, mark: "~", word: "changed", look: "text-[#6B6B6B]" }, - { n: deleted, mark: "-", word: "deleted", look: "text-[#B42318]" }, - ].filter((p) => p.n > 0); - if (!parts.length) return null; - const files = added + modified + deleted; - const tip = `${files} ${files === 1 ? "file" : "files"}: ${parts.map((p) => `${p.n} ${p.word}`).join(", ")}`; - return ( - - {parts.map((p) => ( - - {p.mark} - {p.n} - - ))} - - ); -} - /** * The newest deploy's log, folded. Opened, it's read from the start, then on from where it got to * (`next`): at once while there's more, every few seconds while the deploy waits or runs. A read that @@ -1397,9 +1233,7 @@ function DeployLog({ ws, name, live }: { ws: string; name: string; live: NonNull return (
@@ -1454,7 +1288,7 @@ const CHECKS = 30; * user's Cloudflare, is checked every few seconds until it's live (a couple of minutes at most), its line * saying so. Each line is one 32px line, so what's read comes in under it a line at a time. */ -function Domains({ +export function Domains({ ws, app, answer, @@ -1608,9 +1442,7 @@ function Domains({ onClick={() => setAdding(true)} className="-mx-2 mt-0.5 flex h-7 items-center gap-1.5 self-start rounded-lg px-2 text-[12px] leading-4 text-[#6B6B6B] hover:bg-[#F6F6F4] hover:text-ink" > - - - + Add a domain ))} @@ -1721,7 +1553,7 @@ function DomainLine({ }} className={rowTrashBtn} > - + )} @@ -1894,10 +1726,7 @@ function OutsideDns({ function LockIcon() { return ( - - - - + ); } @@ -2082,9 +1911,7 @@ export function DnsZone({ disabled={busy} className="-mx-2 mt-0.5 flex h-7 items-center gap-1.5 self-start rounded-lg px-2 text-[12px] leading-4 text-[#6B6B6B] hover:bg-[#F6F6F4] hover:text-ink disabled:text-[#9A9A98]" > - - - + Add record )} @@ -2118,10 +1945,10 @@ function RecordLine({ r, busy, onEdit, onRemove }: { r: AppDnsRecord; busy: bool ) : ( )} @@ -2208,9 +2035,7 @@ export function DnsRecordForm({ ))} - - - + a.ws === ws).sort((a, b) => b.updatedAt - a.updatedAt); - return ( -
-
- Your Apps - - {apps.length ? `${apps.length} made by your bots · they keep what you put in them` : "Tools your bots make for you. Ask any bot: “make me an app that…”"} - -
- {appsShown(state) && } -
- {apps.map((a) => { - const maker = state.bots.find((b) => b.id === a.botId); - return ( - - ); - })} -
-
- ); + const tools = apps.map(a =>
+ + {a.building ? "Building" : ""} + +
); + return
+ {appsShown(state) ? : <>

Your Apps

{tools.length ?
{tools}
: Ask a bot to make an app for you.}} +
; } /** The line a build leaves in the chat: building (with Show), made or changed (Open, Undo), or why it couldn't. */ diff --git a/components/app/preview-password.tsx b/components/app/preview-password.tsx index e9b3214cb..53667328c 100644 --- a/components/app/preview-password.tsx +++ b/components/app/preview-password.tsx @@ -1,5 +1,7 @@ "use client"; +import { Lock } from "lucide-react"; +import { appIcon } from "./app-controls"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { PASS_COOKIE, type PreviewPassword, type PreviewSite } from "@/lib/hosted-apps"; import { CopyLink } from "./hosted-apps"; @@ -151,10 +153,7 @@ export function usePreviewTabPassword(ws: string, name: string, site: string | u function LockIcon() { return ( - - - - + ); } diff --git a/components/app/shared-apps.tsx b/components/app/shared-apps.tsx index 2074b0ff6..bc4351d4c 100644 --- a/components/app/shared-apps.tsx +++ b/components/app/shared-apps.tsx @@ -1,5 +1,11 @@ "use client"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { appStatus, changesBetween, type DeployState } from "@/lib/hosted-apps"; +import { hasUnpublishedPreview, reviewLabel } from "@/lib/app-review"; +import { AppDisclosure, appAttention, appIcon, appList, appOutline, appRow } from "./app-controls"; +import { AppPassword, AppReview, AppVersions } from "./app-review"; +import { Fold } from "./fold"; import { useState } from "react"; import type { MemberAppAct, SharedApp, SharedApps } from "@/cloud/protocol"; import { DOMAIN_WORDS } from "@/lib/hosted-apps"; @@ -55,9 +61,13 @@ export const SHARED_APPS_WORDS = { live: "Live", notOnWeb: "Not on the web yet", subdomain: "Subdomain", - /** Under an Admin's Publish: where it goes when its production is on GitHub. */ + /** Where an Admin's Publish goes, said as they confirm it: the owner's Bops publishes it. */ + publishLine: "Sends this reviewed preview to the owner's Bops for publishing.", + /** Where it goes, when there's no domain or repo to name. */ + destination: "Publishing destination configured by the owner", + /** As an Admin confirms Publish: where it goes when its production is on GitHub. */ publishGithub: (g: { repo: string; branch: string }) => `Publish sends it to GitHub (${g.repo}, ${g.branch}), where its own deploy puts it live.`, - /** Under an Admin's Publish: its domains aren't pointed at Orgo yet, so a published change isn't what their visitors see. */ + /** As an Admin confirms Publish: its domains aren't pointed at Orgo yet, so a published change isn't what their visitors see. */ publishWaiting: (domains: string, many = false) => `Visitors to ${domains} won't see a publish until ${many ? "their" : "its"} DNS points at Orgo.`, acted: (who: string, a: Pick & { domain?: string; env?: "prod" }) => a.act === "deploy" @@ -112,6 +122,8 @@ export const SHARED_APPS_WORDS = { githubOneFolder: "It runs more than one site, and one GitHub folder is one app, so it publishes on Orgo.", githubKeyNote: "A new repo, branch, folder or workflow needs a GitHub key of this workspace's own in Keys.", linkFirst: "Link it to GitHub first.", + /** Publish, while its computer links it to GitHub or brings GitHub's changes in. */ + githubWait: "Its computer is busy with GitHub. Publish once it's done.", }; /** The GitHub form's words here (github-form.tsx GithubForm): Save links it too. */ export const SHARED_GITHUB_FORM_WORDS = { @@ -145,7 +157,6 @@ const BUTTON = "rounded-full px-3 py-1 text-[12px] font-medium leading-4 disable export function SharedAppsPage({ state }: { state: AppState }) { const apps = state.shared?.apps; - const ws = state.shared?.name ?? ""; if (!apps) return null; const write = apps.level === "write"; // An Admin whose owner's Bops publishes for them (Bops Cloud says so only then: ADMIN_PUBLISH_MIN_VERSION). @@ -153,22 +164,17 @@ export function SharedAppsPage({ state }: { state: AppState }) { // Where apps publish on GitHub, their Link and Bring in: an Admin's, or a Member's with App secrets (GITHUB_ACTS_MIN_VERSION). const github = write && state.shared?.appGithub === true; return ( -
-
- {W.title} - {write ? W.writeLine(ws) : W.readLine(ws)} - {apps.at > 0 && {W.asOf(asOf(apps.at))}} -
+
+

{W.title}

{apps.at > 0 && {W.asOf(asOf(apps.at))}}
{!apps.list.length ? ( {W.none} ) : ( -
+
{apps.list.map((a) => ( x.act.app === a.name)} /> ))}
)} - {[W.noVariables, W.noLogs, W.noBuying].join(" ")}
); } @@ -179,6 +185,7 @@ type Pending = NonNullable["appActs"]>[number]; function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: SharedApp; apps: SharedApps; acts: Pending[]; publish: boolean; github?: boolean }) { const [open, setOpen] = useState(false); const [said, setSaid] = useState(null); + const [env, setEnv] = useState<"preview" | "prod">("preview"); const write = apps.level === "write"; // Up top, its own site's Preview, where a member's Deploy goes; Published's from an owner's Bops that sends no Preview. // By its link: Bops Cloud drops a null previewLive (a Preview with no deploy yet). @@ -191,60 +198,49 @@ function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: S setSaid(null); const r = await memberAct(act); if (!r.ok) setSaid(r.error ?? W.failed); + return r.ok; }; const newest = a.acts?.[0]; - // A Link or Bring in its computer is doing now: nothing else on the app until it's said how it went. - const busy = !!going || !!a.acts?.some((x) => x.going); - // Production on GitHub not linked yet (a Bops Cloud that says so: its folder is there at Write): Publish would be refused. - const unlinked = !!a.github && a.github.path !== undefined && !a.github.linked; - // Where an Admin's Publish goes, said under it: GitHub, or Published on domains that don't point at Orgo yet. + // A Link or Bring in its computer is doing now: nothing else on the app until it's said how it went (a Preview deploy isn't the mirror's). + const ghGoing = !!a.acts?.some((x) => x.going); + const busy = !!going || ghGoing; + const deployState = (s: string | null | undefined): DeployState | null => s === "queued" || s === "running" || s === "succeeded" || s === "failed" || s === "cancelled" || s === "superseded" ? s : null; + // Production on GitHub: Live is the changeset last sent there, as the owner's own Your Apps reads it (lib/app-review.ts + // statusForApp), never Orgo's Published, which a push doesn't move. Bops Cloud says it as `at` (null: not linked yet); one + // from before says it at Write only (`linked`, with its folder `path`), and one before that not at all: Published's then, + // and linked as far as this page knows (the owner's Bops says so if it isn't, and Publish stays pressable as before). + const gh = a.github; + const ghAt = !gh ? undefined : gh.at !== undefined ? gh.at : gh.linked ? gh.linked.ingot : gh.path !== undefined ? null : undefined; + const liveAt = ghAt !== undefined ? ghAt : site?.serving ?? null; + const status = appStatus({ + head: a.head?.id ?? null, + preview: pv ? { serving: serving ?? null, state: deployState(live?.state) } : null, + live: { serving: liveAt, state: deployState(site?.live?.state) }, + domain: !!site?.domains?.some(d => d.status !== "failed" && d.status !== "disabled"), + wasLive: !!site?.serving, + lost: a.lost, + ...(gh ? { github: { linked: ghAt !== null } } : {}), + changes: changesBetween(a.commits ?? [], liveAt, serving ?? null), + }); + // Publish waits while its computer works on GitHub, and until it's linked there (it would be refused). + const whyPublish = ghGoing ? W.githubWait : ghAt === null ? W.linkFirst : undefined; + // Where an Admin's Publish goes, said where they confirm it: GitHub, or Published on domains that don't point at Orgo + // yet (its visitors don't see a publish until they do), else its domain. const waitingOn = site?.domains?.length && site.domains.every((d) => d.status !== "active") && !site.url ? site.domains.map((d) => d.domain) : []; - const where = !publish ? null : a.github ? W.publishGithub(a.github) : waitingOn.length ? W.publishWaiting(waitingOn.join(" and "), waitingOn.length > 1) : null; + const where = gh ? W.publishGithub(gh) : waitingOn.length ? W.publishWaiting(waitingOn.join(" and "), waitingOn.length > 1) : null; + const destination = gh ? `${gh.repo} · ${gh.branch}` : (site?.url ?? a.url)?.replace(/^https?:\/\//, "") ?? (waitingOn.length ? waitingOn.join(" and ") : W.destination); return ( -
-
- - - {write ? ( - <> - - {/* An Admin's Publish, as the owner's: what its Preview serves goes on Published (the changeset seen here, for an app of one site). */} - {publish && ( - - - - )} - - ) : ( - - - - )} + {open ? "" : reviewLabel(status)} + {!open && }
- {(where || going || acts.some((x) => x.state === "failed") || said || newest) && ( + {(going || acts.some((x) => x.state === "failed") || said || newest) && (
- {where && ( - - {where} - - )} {going && {going.state === "waiting" ? W.waiting : W.sending}} {acts .filter((x) => x.state === "failed") @@ -266,9 +262,14 @@ function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: S )}
)} - {!write && open && {W.readOnly}} - {open && ( -
+
+ {!write && {W.readOnly}} + : undefined} + onUpdate={write ? () => void send({ act: "deploy", app: a.name }) : undefined} + onPublish={publish ? at => send({ act: "deploy", app: a.name, env: "prod", ...(pv && !a.siteNames?.length ? { at } : {}) }) : undefined} + history={a.commits?.length ? void send({ act: "deploy", app: a.name, at: id }) : undefined}/> : undefined} + technical={
{(a.sites ?? []).map((s) => ( @@ -288,22 +289,6 @@ function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: S ))} - {!!a.commits?.length && ( - - {a.commits.map((c) => ( - - {shortId(c.id)} - {c.message} - {`${c.author} · ${ago(c.at)}`} - {write && ( - - )} - - ))} - - )} {!!a.deploys?.length && ( {a.deploys.map((d) => ( @@ -323,10 +308,11 @@ function SharedAppCard({ app: a, apps, acts, publish, github = false }: { app: S )} {write && github && void send(act)} />} - {write && a.settings && void send(act)} />} + {write && a.settings && void send(act)}/>} {write && !!apps.owned?.length && void send(act)} />} -
- )} + {[W.noVariables, W.noLogs, W.noBuying].join(" ")} +
}/> +
); } diff --git a/components/app/ui.tsx b/components/app/ui.tsx index f299e6a69..9427e431f 100644 --- a/components/app/ui.tsx +++ b/components/app/ui.tsx @@ -334,6 +334,7 @@ export function AskInPlace({ action, danger = false, busy = false, + disabled = false, error, focus = "action", compact = false, @@ -348,6 +349,7 @@ export function AskInPlace({ action: string; danger?: boolean; busy?: boolean; + disabled?: boolean; error?: string | null; focus?: "action" | "cancel"; compact?: boolean; @@ -400,7 +402,7 @@ export function AskInPlace({ -
diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 353b72909..b03adaffc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,7 +1,18 @@ # Feature checks +Each row says how to reach a feature, what it should do, and how to check it. `scripts/verify --list` names every target. + +Your Apps: `scripts/verify your-apps` runs the focused type, lint, request, permission, password and UI component checks. Add `--ui` to drive both 1440px and 1180px windows on the sample two-person stack. The browser fixtures answer website and publishing requests locally; these checks do not claim vendor deployment success. Bops Cloud's part (SharedApp.github) is in `cloud/test/core-members.test.ts`, which needs the test database. + | Feature | How to reach it | Expected result | Files | Verify | |---|---|---|---|---| | Browser latency measurement | `node scripts/probe-browser-speed.mjs --link-ms 0 --out .scratch/probe-0` (repeat with 35) | 85 colour changes including ten clicks after six seconds idle, five seconds scrolling, raw JSON and hop/connection summaries, cleanup | `scripts/browser-speed/`, `scripts/probe-browser-speed.mjs`, `docs/BROWSER-SPEED.md` | `scripts/verify browser-speed`, then the command in this row | | Computer draw and encode measurement | `node scripts/probe-computer-side.mjs --out .scratch/computer-probe` | 30 clicks, five seconds scrolling, owned Chrome stopped | `scripts/probe-computer-side.mjs` | Same static gate, then this row's command | | Browser tab input on one socket | Owner's Bops, the bot's browser tab, Take over, then move, click, scroll and type on the page (`npm run two-person`) | Inputs reach the page in order; moves and scrolls waiting are replaced by the newest (scrolls summed); presses, keys and pages never dropped; an older Bops Cloud (plain 404 on WS /v1/browser/input) gets every act by POST | `cloud/browser.ts`, `lib/server/browser-view.ts`, `components/app/bot-browser.tsx` | `scripts/verify browser-input`, then `node scripts/test-browser-tab-e2e.mjs` and the latency probe row | +| Your Apps | Sidebar → Your Apps | Equal rows for websites and tools; changed websites lead to review; no invitation to add one where hosting is closed | `made-apps.tsx`, `hosted-apps.tsx`, `app-controls.tsx` | `scripts/verify your-apps` (`--ui` for the browser pass) | +| Review and publish | Open a website → Review preview | Yellow marks a pending preview/release difference; final confirmation names the reviewed version and destination; a failed preview update still lets what the preview serves be published | `app-review.tsx`, `app-envs.tsx`, `lib/app-review.ts` | `scripts/verify your-apps` | +| Password | Preview → Show password | Reveal, copy, Escape; current password fetched from the existing protected route | `app-review.tsx`, `preview-password.tsx` | `scripts/verify your-apps` | +| Versions | Version history → Restore to preview | Original preview deploy request; publishing remains a separate choice; an app of more than one site says all sites | `hosted-apps.tsx`, `shared-apps.tsx`, `lib/hosted-apps.ts` | `scripts/verify your-apps` | +| Advanced actions | Technical details | Local controls for owners; existing GitHub, rollback, and deployment actions | `app-envs.tsx` | `scripts/verify your-apps` | +| App settings | Settings → Technical settings | Existing site/environment commands, variables, domains, and deploy controls | `hosted-apps.tsx`, `app-settings.tsx` | `scripts/verify your-apps` | +| Shared apps | Admin's shared workspace → Your Apps | Same review flow; only authorized member actions; no Local, variable values, logs, or domain buying. An Admin keeps Update preview beside Publish; Publish's confirmation says where it goes (GitHub repo and branch, or a domain not pointed at Orgo yet); an app on GitHub reads Live from the last push; whoever may change where it publishes links it and brings GitHub's changes in from Technical details | `shared-apps.tsx`, `cloud/ws-slice.ts` | `scripts/verify your-apps`, and `node --test cloud/test/core-members.test.ts` with the test database | diff --git a/docs/YOUR-APPS-V4.md b/docs/YOUR-APPS-V4.md new file mode 100644 index 000000000..36d131894 --- /dev/null +++ b/docs/YOUR-APPS-V4.md @@ -0,0 +1,31 @@ +# Your Apps review flow + +The home has one list for websites and tools. A changed website opens review rather than publishing directly. Preview is the review copy; Live shows the published version, or the version last sent to GitHub. Technical detail stays available one level deeper. + +| Acceptance | Proof | +|---|---| +| Equal rows at both widths; no Local controls or direct publish on home | Browser checks (the before and after pictures are in the PR description's history, not in the repo) | +| Review preview, reveal/copy password, Escape, Live, and Ask Boppy do what they say | Browser checks at 1440px and 1180px | +| Pending previews and review entries use the existing highlighter; quiet/working/failed states do not borrow it | Rendered UI regression and browser color checks | +| Final confirmation quiets the earlier review action; cancellation restores it; editing remains available | Mounted UI, browser checks and Ask Boppy's unsent draft | +| In the narrow owner view, Publish stays left-aligned on its second action line | Browser bounding boxes at 1180px | +| Publishing names the version/destination, allows cancellation, and refuses a moved preview | Browser checks; server retains its existing pinned request | +| Restore changes Preview and preserves a separate Publish action | Browser checks and test-app-status.mjs | +| Settings, domains, rollback, GitHub tools, multi-site actions, Local and copies retain their request bodies | test-hosted-apps-ui.mjs; representative owner/Admin browser requests | +| Members keep Read/Write/Admin boundaries; owner-only tools stay private | test-hosted-apps-ui.mjs and test-shared.mjs | +| An Admin keeps Update preview beside Publish, and never gets an Add a domain they can't use | test-hosted-apps-ui.mjs | +| An Admin's review of an app on GitHub reads Live from the last push (SharedApp.github.at), and Publish's confirmation says where it goes: the repo and branch, or a domain not pointed at Orgo yet | test-hosted-apps-ui.mjs and cloud/test/core-members.test.ts | +| A failed preview update still lets what the preview serves be published | test-app-status.mjs | +| Existing Bops controls, shared CRM picker, lucide icons, reduced motion | Source audit, types/lint, browser pictures | + +Run `scripts/verify your-apps --ui`. The browser uses the local sample stack and answers publishing requests locally. It proves the UI and requests, not real GitHub or production deployment. No domain feature, dependency, auth contract or server handler changes. Bops Cloud adds one field to what members see of an app on GitHub (SharedApp.github.at, the changeset last sent there); from a Bops Cloud without it, an Admin's review reads Live from Published as before and Publish stays pressable. Revert the feature commit to roll back the UI. + +This fresh pass replaces the rejected mock in Draft #184. #179's words for where Publish goes (GitHub, or a domain not pointed at Orgo yet) are said in an Admin's Publish confirmation, as its line and destination. + +The approved [Paper color pass](https://app.paper.design/file/01M7177KTDR3NA8P96W6S1GT14/p-1-0/7B-0) emphasizes a pending decision with Bops' highlighter and a named Review preview action. “Unpublished” retains its preview/release meaning; review approval is not tracked and editing stays available. Info recommends reviewing the batch before unrelated work, while allowing refinement of the same change. The final publish confirmation keeps the preceding review action quiet. + +Original v4 review: Claude Opus 5.5 approved after one full review and a bounded delta. Checks: types/lint clean; app UI 80, status 13, password 10, sharing 70; browser 72 PASS with zero page/console errors at both widths. Seven review regressions failed before their fixes and pass afterward. + +Color follow-up checks: types/lint clean; app UI 83, status 13, password 10, sharing 70. Browser checks: 91 PASS at 1440/1180 with no page or console errors. Three new regressions were added; the relevant pending/confirmation cases failed before implementation. + +Claude Opus 5.5 independently reran the focused gate and approved this color follow-up after one full review and a bounded screenshot delta. Captures finish finite animations before saving; both Admin pictures now show the open review controls. diff --git a/lib/app-review.ts b/lib/app-review.ts new file mode 100644 index 000000000..403d058c0 --- /dev/null +++ b/lib/app-review.ts @@ -0,0 +1,39 @@ +import { appStatus, changesBetween, type HostedApp, type AppCommit, type AppStatusInput } from "./hosted-apps"; + +export type ReviewStatus = ReturnType; + +/** A preview/release difference, not a recorded approval or an editing lock. */ +export const hasUnpublishedPreview = (status: ReviewStatus) => status.action === "publish" && status.tone === "waits"; + +/** The Preview and the destination it was last sent to, never a claim about a GitHub deployment. */ +export function statusForApp(app: HostedApp, commits: AppCommit[] = []): ReviewStatus { + const gh = app.hosting?.github; + const live = gh ? gh.last?.ingot ?? null : app.serving; + return appStatus({ + head: app.head, + preview: app.preview ? { serving: app.preview.serving, state: app.preview.live?.state ?? null } : null, + live: { serving: live, state: app.live?.state ?? null }, + lost: !!app.hosting?.lost, + domain: !!app.domains?.some(d => d.status !== "failed" && d.status !== "disabled"), + wasLive: !!app.serving || app.live?.state === "succeeded", + ...(gh ? { github: { linked: !!gh.last } } : {}), + changes: changesBetween(commits, live, app.preview?.serving ?? null), + }); +} + +export function reviewLabel(status: ReviewStatus, input?: Pick): string { + if (status.sentence.startsWith("Its computer is gone")) return "Needs a new home"; + if (status.tone === "working") return status.sentence.includes("preview") ? "Updating preview" : "Publishing"; + if (status.tone === "failed") return status.sentence.startsWith("The last preview update failed") ? "Preview update failed" : "Publish failed"; + if (status.action === "update-preview") return "Preview needs updating"; + if (status.action === "connect-domain") return "Not live"; + if (status.action === "publish") { + if (status.sentence.includes("earlier")) return "Earlier version in preview"; + const n = status.sentence.match(/has (\d+) change/)?.[1]; + return n ? `${n} unpublished${n === "1" ? " change" : " changes"}` : "Unpublished changes"; + } + if (status.sentence === "Live and up to date.") return "Up to date"; + if (status.sentence.includes("Link it")) return "Connect GitHub"; + if (input?.preview?.serving && !input.live.serving) return "Not live"; + return status.sentence.replace(/\.$/, ""); +} diff --git a/lib/hosted-apps.ts b/lib/hosted-apps.ts index 6a004bc14..80d39866f 100644 --- a/lib/hosted-apps.ts +++ b/lib/hosted-apps.ts @@ -812,3 +812,78 @@ export const MEMBER_ACTS_KEPT = 10; /** An app's sites by name: its own (named as the app), then AppHosting.sites in order. */ export const sitesOfHosting = (h: Pick, name: string): string[] => [name, ...(h.sites ?? []).map((s) => s.name)]; + +/* ---------------- What's going on with an app, in a sentence, and the one thing to press ---------------- */ + +/** The one thing to press for an app now (undefined: nothing; it's up to date, or something is on its way). */ +export type AppNextAction = "update-preview" | "publish" | "connect-domain"; + +/** What an app's status is made of, however the window has the app (the owner's HostedApp, or a member's SharedApp). */ +export type AppStatusInput = { + /** The newest changeset on main (null: nothing committed yet). */ + head: string | null; + /** Its Preview: what it serves and its newest deploy's state (null: no Preview yet). */ + preview: { serving: string | null; state: DeployState | null } | null; + /** Published: what it serves (on GitHub, the changeset last pushed) and its newest deploy's state. */ + live: { serving: string | null; state: DeployState | null }; + /** It has a domain Published can live on (or, on GitHub, a repo it's linked to). */ + domain: boolean; + /** Published served something before (a legacy site with no domain publishes all the same). */ + wasLive: boolean; + /** Its computer is gone, and its deploy projects with it. */ + lost?: boolean; + /** Production is on GitHub, and whether it was linked there yet. */ + github?: { linked: boolean }; + /** How many changes on main are on the Preview but not live, when the history says (null or absent: not known). */ + changes?: number | null; + /** The newest change's words, when there's one not live, for the long form. */ + change?: string | null; +}; + +/** + * An app's status in one plain sentence: what the preview has, whether live is behind it, and what's on its way; with + * the one thing to press now (`action`) and how it reads (`tone`: quiet, something waits on the person, something is + * being done, or something failed). `detail` names the change that isn't live, for a page with the room for it. + */ +export function appStatus(i: AppStatusInput): { sentence: string; detail?: string; action?: AppNextAction; tone: "quiet" | "waits" | "working" | "failed" } { + const going = (s: DeployState | null | undefined) => s === "queued" || s === "running"; + if (i.lost) return { sentence: "Its computer is gone. Update the preview to give it a new home.", action: "update-preview", tone: "waits" }; + if (!i.head) return { sentence: "Nothing built yet.", tone: "quiet" }; + if (going(i.preview?.state)) return { sentence: "Updating the preview…", tone: "working" }; + if (going(i.live.state)) return { sentence: i.github ? "Pushing to GitHub…" : "Publishing…", tone: "working" }; + // What the preview holds that isn't live is there to publish, the newest change or an earlier version put back on it + // (Restore): the preview is "behind" only while it has nothing, or what's live already, and the newest change isn't on it. + const holds = !!i.preview?.serving && i.preview.serving !== i.live.serving; + const behind = !i.preview?.serving || (i.preview.serving !== i.head && !holds); + const canLive = i.domain || i.wasLive || !!i.github; + // A failed preview update leaves the preview serving what it did: when that isn't live and can go live, publishing it + // is still the press (the failed build is retried with Update preview, beside it or in Technical details). + if (i.preview?.state === "failed") + return holds && canLive && (!i.github || i.github.linked) + ? { sentence: "The last preview update failed. What the preview still has can be published.", action: "publish", tone: "failed" } + : { sentence: "The last preview update failed.", action: "update-preview", tone: "failed" }; + // (The press that can go through now: the preview to update first when it's behind, else publishing again.) + if (i.live.state === "failed") return { sentence: "The last publish failed.", action: behind ? "update-preview" : "publish", tone: "failed" }; + if (behind || !i.preview) return { sentence: "The preview is behind the latest change.", action: "update-preview", tone: "waits" }; + if (i.github && !i.github.linked) return { sentence: "The preview is ready. Link it to GitHub to publish.", tone: "waits" }; + if (i.preview.serving === i.live.serving) return canLive ? { sentence: "Live and up to date.", tone: "quiet" } : { sentence: "Not published yet.", action: "connect-domain", tone: "waits" }; + if (!canLive) return { sentence: "The preview is ready. Connect a domain to publish it.", action: "connect-domain", tone: "waits" }; + // (An earlier version put back on the preview, to go back to: the history counts no change between live and it.) + if (i.preview.serving !== i.head && i.changes === 0) return { sentence: "The preview has an earlier version than live.", ...(i.change ? { detail: i.change } : {}), action: "publish", tone: "waits" }; + const n = i.changes; + const sentence = n == null ? "The preview has changes that aren't live." : n === 1 ? "The preview has 1 change that isn't live." : `The preview has ${n} changes that aren't live.`; + return { sentence, ...(i.change ? { detail: i.change } : {}), action: "publish", tone: "waits" }; +} + +/** + * How many changes on main sit between what's live and what the preview serves, from the history (newest first): + * null when either isn't in it (an older page, or nothing live yet), 0 when they're the same. + */ +export function changesBetween(commits: { id: string }[], live: string | null, preview: string | null): number | null { + if (!preview) return null; + const p = commits.findIndex((c) => c.id === preview); + if (p < 0) return null; + if (!live) return null; + const l = commits.findIndex((c) => c.id === live); + return l < 0 ? null : Math.max(0, l - p); +} diff --git a/lib/server/github-mirror.ts b/lib/server/github-mirror.ts index 102da3fe1..9b596608c 100644 --- a/lib/server/github-mirror.ts +++ b/lib/server/github-mirror.ts @@ -438,7 +438,7 @@ async function readGithub(gh: GitHub, cfg: GithubMirror) { */ export async function mirrorPublish(wsId: string, name: string, at: string, cfg: GithubMirror, author: string): Promise { const last = cfg.last; - if (!last) throw new MirrorError(`${name} isn't linked to GitHub yet. Link it first, in the menu beside Publish.`, 409, { code: "NOT_LINKED" }); + if (!last) throw new MirrorError(`${name} isn't linked to GitHub yet. Link it first, from Technical details in Your Apps.`, 409, { code: "NOT_LINKED" }); if (!(await atOrAfter(wsId, name, at, last.ingot))) throw new MirrorError("The preview is older than what GitHub has (its changes came into Ingot since). Deploy the newest to the preview first, then publish.", 409, { code: "PREVIEW_BEHIND" }); const gh = github(wsId); @@ -468,7 +468,7 @@ export async function mirrorPublish(wsId: string, name: string, at: string, cfg: const newer = (headFiles: Files) => { for (const [p, f] of theirs) if (!unlinked.has(p) && !same(headFiles.get(p), baseFiles.get(p)) && !same(headFiles.get(p), f) && !both.includes(p)) both.push(p); if (both.length) - throw new MirrorError(`Both Ingot and GitHub changed ${list(both)} since they were last the same. Bring in GitHub's changes (in the menu beside Publish) to take GitHub's version, then make Ingot's change again. Nothing was published.`, 409, { code: "BOTH_CHANGED", paths: both }); + throw new MirrorError(`Both Ingot and GitHub changed ${list(both)} since they were last the same. Bring in GitHub's changes (from Technical details in Your Apps) to take GitHub's version, then make Ingot's change again. Nothing was published.`, 409, { code: "BOTH_CHANGED", paths: both }); }; const ingotHead = await ingot.head(wsId, name); newer(theirs.size && ingotHead && ingotHead !== at ? (await ingotSide(wsId, name, ingotHead, (p) => !theirs.has(p))).files : oursNow.files); @@ -560,7 +560,7 @@ async function pushUp( * history), and the point moves to the branch's newest. What only Ingot changed stays, for the next Publish. */ export async function mirrorPull(wsId: string, name: string, cfg: GithubMirror, author: string): Promise { - if (!cfg.last) throw new MirrorError(`${name} isn't linked to GitHub yet. Link it first, in the menu beside Publish.`, 409, { code: "NOT_LINKED" }); + if (!cfg.last) throw new MirrorError(`${name} isn't linked to GitHub yet. Link it first, from Technical details in Your Apps.`, 409, { code: "NOT_LINKED" }); const gh = github(wsId); const { head, baseFiles, theirs, unlinked } = await readGithub(gh, cfg); massDelete(baseFiles, theirs, "GitHub"); diff --git a/lib/server/hosted-apps.ts b/lib/server/hosted-apps.ts index df24b5383..a217ad7d9 100644 --- a/lib/server/hosted-apps.ts +++ b/lib/server/hosted-apps.ts @@ -5508,7 +5508,7 @@ export function hostedAppsLine(botId: string): string { const copies = shared ? ` When more bots work on one app, each works in a copy of its own with Locals of its own, and all commit to the same main: ${SYNC_WORDS} Never change another bot's copy.` : ""; return [ `Your workspace's apps on the web, each an Ingot repo, its source: ${apps.map(one).join("; ")}.`, - `Each has the three environments Your Apps shows, and one command acts on each, the same as its buttons there: Local, the live view while you work (its working folder as a dev server with hot reload on its bot's computer, each site at its own http://localhost:, the same on its computer's screens and beside this chat, where ${owner} watches it): local_app starts, stops or reads it. Preview, its fixed link to test and share (Deploy in Your Apps): deploy_app puts a commit there. It asks for a password unless ${owner} turned it off (${owner} sees it beside the Preview link in Bops; you never have it, so never write one in this chat or ask for it). Published, its production (Publish in Your Apps): deploy_app with where prod puts what Preview serves there, on its own domain, or for an app whose production is on GitHub, into its folder there, whose own workflow deploys it. Publish only when ${owner}'s or an Admin's own words ask for it.`, + `Each has the three environments Your Apps shows, and one command acts on each, the same as its buttons there: Local, the live view while you work (its working folder as a dev server with hot reload on its bot's computer, each site at its own http://localhost:, the same on its computer's screens and beside this chat, where ${owner} watches it): local_app starts, stops or reads it. Preview, its fixed link to test and share (Update preview in Your Apps): deploy_app puts a commit there. It asks for a password unless ${owner} turned it off (${owner} sees it beside the Preview link in Bops; you never have it, so never write one in this chat or ask for it). Published, its production (Publish in Your Apps): deploy_app with where prod puts what Preview serves there, on its own domain, or for an app whose production is on GitHub, into its folder there, whose own workflow deploys it. Publish only when ${owner}'s or an Admin's own words ask for it.`, `When ${owner} asks for any change to an app, call work_on_app at once, with no question first: it makes the change in its working folder, commits it to Ingot and deploys that commit to its Preview, then says the link and what changed, so ${owner} can test it there. Never ask whether you may use Ingot, make a draft or deploy a preview: only publishing waits for their words. Change an app only that way: never through GitHub (its API, a branch, a pull request, change_files) or by calling Orgo yourself, even when its production is on GitHub. Never open a Preview link to check it (its password page answers 401): check your work on Local. Say the environment and its address when you point ${owner} at one.${copies}${repos ? ` ${repos}` : ""}`, ].join("\n"); } diff --git a/scripts/fixtures/your-apps.mjs b/scripts/fixtures/your-apps.mjs new file mode 100644 index 000000000..ef17ffa7b --- /dev/null +++ b/scripts/fixtures/your-apps.mjs @@ -0,0 +1,248 @@ +// Sample apps for Your Apps on the local two-person stack (scripts/dev-two-person.mjs), answered at the browser's edge by +// scripts/test-your-apps-e2e.mjs: the owner's Bops is shown three apps on the web and two tools the bots made, and sam +// (an Admin) is shown the same apps as the owner's Mac would have written them for members. Nothing here is real: every +// name, link and password is made up, and no request these answer ever leaves this Mac. +import { BOT, COMPUTER, OWNER, PROJECT, SAM } from "./two-person.mjs"; + +/** An Orgo team sign-in: apps are open to it (cloud/apps-team.ts). The owner's, in place of their made-up one. */ +export const TEAM_EMAIL = "tess@orgo.ai"; + +const NOW = Date.now(); +const min = (n) => NOW - n * 60_000; +const hr = (n) => min(n * 60); +/** A changeset id: 40 hex characters, made of a short mark. */ +export const id = (mark) => mark.repeat(40).slice(0, 40); + +/* ---------------- fourcorners: the preview has one change that isn't live ---------------- */ + +export const FC = { + name: "fourcorners", + head: id("a1"), + prodAt: id("b2"), + preview: "https://ggn7sjpkwnz8.orgo.dev", + label: "ggn7sjpkwnz8", + domain: "fourcorners.brandlord.agency", + password: "Kw7mQ2p", +}; +export const CCLC = { name: "cclc", head: id("c1"), preview: "https://q4vd9kz2mt8p.orgo.dev", label: "q4vd9kz2mt8p" }; +export const BROCHURE = { name: "brochure-maker", head: id("d2"), prodAt: id("d1"), preview: "https://m2kq8vxs7bhw.orgo.dev", label: "m2kq8vxs7bhw", domain: "brochures.fourcorners.travel" }; + +const live = (dId, at, state, when) => ({ id: dId, at, state, when, error: null, url: null }); + +/** The commits of each app, newest first (AppCommit). */ +export const COMMITS = { + fourcorners: [ + { id: FC.head, message: "Home line: Pick a project type on the left to get started.", author: "Four Corners", at: min(8), changes: { added: 0, modified: 1, deleted: 0 } }, + { id: FC.prodAt, message: "IMPROVING.md: preview first, then Publish to GitHub", author: "Four Corners", at: hr(26), changes: { added: 1, modified: 0, deleted: 0 } }, + { id: id("c3"), message: "Agent guide: a route per request kind", author: "Orgo team", at: hr(70), changes: { added: 0, modified: 3, deleted: 0 } }, + { id: id("d4"), message: "Brochure template: photo under the title on covers", author: "Four Corners", at: hr(96), changes: { added: 2, modified: 4, deleted: 1 } }, + ], + cclc: [ + { id: CCLC.head, message: "Board members page: photos and titles", author: "CCLC", at: hr(3), changes: { added: 1, modified: 2, deleted: 0 } }, + { id: id("e5"), message: "From GitHub: 68 files", author: "Tiger", at: hr(50), changes: { added: 68, modified: 0, deleted: 0 } }, + ], + "brochure-maker": [ + { id: BROCHURE.head, message: "Export as PDF from the Share menu", author: "Boppy", at: min(3), changes: { added: 1, modified: 2, deleted: 0 } }, + { id: BROCHURE.prodAt, message: "First version: three brochure layouts", author: "Boppy", at: hr(30), changes: { added: 12, modified: 0, deleted: 0 } }, + ], +}; + +/** What the owner's page reads (GET /api/hosted-apps), as it is now: the drive changes it as buttons are pressed. */ +export function hostedApps() { + return { + enabled: true, + apps: [ + { + name: FC.name, + head: FC.head, + updatedAt: min(8), + hosting: { + botId: BOT.id, + computerId: COMPUTER.id, + projectId: "p_fc", + preview: FC.label, + github: { repo: "OrgoAI/orgo-brandlord", branch: "main", path: "formula", keep: ["clients/cclc/"], last: { sha: "3c04ce04f1a9b2d7e6c5a4b3c2d1e0f9a8b7c6d5", ingot: FC.prodAt, when: hr(26) } }, + }, + serving: FC.prodAt, + live: live("d_fc_1", FC.prodAt, "succeeded", hr(26)), + preview: { projectId: "pp_fc", url: FC.preview, label: FC.label, serving: FC.head, live: live("d_fc_p1", FC.head, "succeeded", min(6)), protected: true, passwordAt: min(6) }, + domain: `https://${FC.domain}`, + domains: [{ domain: FC.domain, status: "active" }], + }, + { + name: CCLC.name, + head: CCLC.head, + updatedAt: hr(3), + // (Its Published project exists (made at its first settings save), with no domain yet: Add a domain is offered.) + hosting: { botId: BOT.id, computerId: COMPUTER.id, projectId: "p_cclc", preview: CCLC.label }, + serving: null, + live: null, + preview: { projectId: "pp_cclc", url: CCLC.preview, label: CCLC.label, serving: CCLC.head, live: live("d_cclc_p1", CCLC.head, "succeeded", hr(3)), protected: true, passwordAt: hr(3) }, + domain: null, + }, + { + name: BROCHURE.name, + head: BROCHURE.head, + updatedAt: min(3), + hosting: { botId: BOT.id, computerId: COMPUTER.id, projectId: "p_br", preview: BROCHURE.label }, + serving: BROCHURE.prodAt, + live: live("d_br_1", BROCHURE.prodAt, "succeeded", hr(30)), + preview: { projectId: "pp_br", url: BROCHURE.preview, label: BROCHURE.label, serving: BROCHURE.prodAt, live: live("d_br_p2", BROCHURE.head, "running", min(1)), protected: true, passwordAt: hr(30) }, + domain: `https://${BROCHURE.domain}`, + domains: [{ domain: BROCHURE.domain, status: "active" }], + }, + ], + }; +} + +/** What the owner's state carries of them (Workspace.hostedApps), for appsStamp and Work on it. */ +export const hostingInState = () => Object.fromEntries(hostedApps().apps.map((a) => [a.name, a.hosting])); + +/** A site's settings and recent deploys (GET …/settings), Published's or the Preview's. */ +export function settings(name, env) { + const a = hostedApps().apps.find((x) => x.name === name); + const deploys = + name === FC.name + ? env === "preview" + ? [ + { id: "d_fc_p1", state: "succeeded", at: FC.head, rollback: false, when: min(6), error: null, live: true, canRollback: false }, + { id: "d_fc_p0", state: "succeeded", at: FC.prodAt, rollback: false, when: hr(26), error: null, live: false, canRollback: true }, + ] + : [ + { id: "d_fc_1", state: "succeeded", at: FC.prodAt, rollback: false, when: hr(26), error: null, live: true, canRollback: false }, + { id: "d_fc_0", state: "succeeded", at: id("c3"), rollback: false, when: hr(70), error: null, live: false, canRollback: true }, + ] + : []; + return { + url: env === "preview" ? (a?.preview?.url ?? null) : null, + settings: name === CCLC.name && env !== "preview" ? null : { port: env === "preview" ? 3101 : 3100, install: "npm ci", build: "npm run build", start: "npm start", health: "/" }, + env: env === "preview" ? ["DATABASE_URL", "NEXTAUTH_SECRET", "PREVIEW_BANNER"] : ["DATABASE_URL", "NEXTAUTH_SECRET"], + deploys, + ...(env === "preview" ? { prodEnv: ["DATABASE_URL", "NEXTAUTH_SECRET"] } : {}), + }; +} + +/** Its domains and their DNS (GET …/dns). */ +export function dns(name) { + if (name === FC.name) + return { + url: null, + domains: [{ id: "dom_fc", domain: FC.domain, status: "active", cert: "issued", managed: false, zone: null, publish: [{ type: "CNAME", name: "fourcorners", value: "edge.orgo.dev", found: "ok" }], where: "Cloudflare" }], + zones: [], + }; + if (name === BROCHURE.name) + return { + url: null, + domains: [{ id: "dom_br", domain: BROCHURE.domain, status: "active", cert: "issued", managed: true, zone: "z_fct", publish: [] }], + zones: [ + { + id: "z_fct", + domain: "fourcorners.travel", + serving: true, + ttl: 300, + records: [ + { id: "r1", type: "A", name: "brochures", fqdn: BROCHURE.domain, value: "203.0.113.10", locked: "Orgo wrote it so brochures.fourcorners.travel reaches its computer." }, + { id: "r2", type: "TXT", name: "", fqdn: "fourcorners.travel", value: "v=spf1 -all", locked: null }, + ], + renewal: { on: true, cents: 1400, years: 1, payer: "you", state: "scheduled", error: null, expires: NOW + 200 * 86_400_000 }, + }, + ], + }; + return { url: null, domains: [], zones: [] }; +} + +/** Its Local, as its computer said (GET …/local). */ +export const local = (name) => ({ sites: { [name]: { state: "stopped", port: 3000, devPort: 3000, command: "npm run dev", since: null, error: null, env: [] } }, bot: { id: BOT.id, name: BOT.name } }); + +/** Its Preview's password (GET …/password). */ +export const password = (name) => { + const a = hostedApps().apps.find((x) => x.name === name); + return { url: a?.preview?.url ?? null, on: true, password: FC.password, cookie: null, at: a?.preview?.passwordAt }; +}; + +/** Options for Add a domain (GET …/domains). */ +export const domainOptions = () => ({ buy: { open: false, why: "Buying domains isn't open here." }, owned: [{ id: "z_fct", domain: "fourcorners.travel", inUse: [{ name: BROCHURE.domain, app: BROCHURE.name }] }], contact: null, cloudflare: { zones: [{ id: "cfz1", domain: "brandlord.agency", inUse: [{ name: FC.domain, app: FC.name }] }] } }); + +/** A deploy's log (GET …/logs). */ +export const log = () => ({ state: "succeeded", log: "Installing…\nBuilding…\nStarting on port 3100\nHealthy.\n", next: null, more: false }); + +/* ---------------- The tools the bots made (tiles) ---------------- */ + +export const MADE = [ + { + id: "app_trip_budget", + title: "Trip budget", + botId: BOT.id, + chatId: `bot:${BOT.id}`, + ws: PROJECT.wsId, + version: 3, + versions: [ + { n: 1, note: "A budget with a line per cost", at: hr(50) }, + { n: 2, note: "Totals per day", at: hr(26) }, + { n: 3, note: "Export as PDF", at: hr(2) }, + ], + createdAt: hr(50), + updatedAt: hr(2), + }, + { + id: "app_brochure_checklist", + title: "Brochure checklist", + botId: BOT.id, + chatId: `bot:${BOT.id}`, + ws: PROJECT.wsId, + version: 1, + versions: [{ n: 1, note: "The checklist, ticked per brochure", at: hr(30) }], + createdAt: hr(30), + updatedAt: hr(30), + }, +]; + +/** What a made app shows in its frame (GET /api/made-apps/[id]/view): a small page in the app's name. */ +export function madeAppPage(appId) { + const a = MADE.find((x) => x.id === appId); + const rows = appId === "app_trip_budget" ? ["Flights · $420", "Hotel · $180", "Guides · $960", "Meals · $75"] : ["Cover photo", "Three days", "Prices checked", "Printed proof"]; + return `${a?.title ?? "App"}

${a?.title ?? "App"}

    ${rows.map((r) => `
  • ${r}
  • `).join("")}
`; +} + +/* ---------------- What sam, an Admin, is shown (SharedApps) ---------------- */ + +/** The owner's rows under each app: what members did (MemberAppAct). */ +export const ACTS = { + fourcorners: [{ key: "act1", userId: SAM.id, name: SAM.name, act: "deploy", env: "prod", ok: true, at: hr(26) }], +}; + +export function sharedApps() { + const apps = hostedApps().apps; + const site = (a) => ({ + name: a.name, + url: a.domain, + preview: a.preview?.url ?? null, + previewLive: a.preview?.live ? { id: a.preview.live.id, state: a.preview.live.state, when: a.preview.live.when } : null, + previewServing: a.preview?.serving ?? null, + ...(a.preview?.protected ? { previewProtected: true, previewPassword: FC.password } : {}), + serving: a.serving, + live: a.live ? { id: a.live.id, state: a.live.state, when: a.live.when } : null, + ...(a.domains ? { domains: a.domains } : {}), + }); + const deploysOf = (a) => + [...settings(a.name, "preview").deploys.map((d) => ({ ...d, site: a.name, env: "preview" })), ...settings(a.name).deploys.map((d) => ({ ...d, site: a.name, env: "prod" }))].map(({ error: _e, ...d }) => (void _e, d)); + return { + level: "write", + at: min(10), + list: apps.map((a) => ({ + name: a.name, + botId: BOT.id, + head: { id: a.head, message: COMMITS[a.name][0].message, at: COMMITS[a.name][0].at }, + // Its production on GitHub, as Bops Cloud says it (cloud/ws-slice.ts githubOf): repo, branch and the changeset last sent there. + ...(a.hosting?.github ? { github: { repo: a.hosting.github.repo, branch: a.hosting.github.branch, at: a.hosting.github.last?.ingot ?? null } } : {}), + sites: [site(a)], + commits: COMMITS[a.name], + deploys: deploysOf(a), + settings: { [a.name]: { port: 3100, install: "npm ci", build: "npm run build", start: "npm start" } }, + ...(ACTS[a.name] ? { acts: ACTS[a.name] } : {}), + })), + owned: [{ id: "z_fct", domain: "fourcorners.travel" }], + }; +} + +export { BOT, COMPUTER, OWNER, PROJECT, SAM }; diff --git a/scripts/test-app-status.mjs b/scripts/test-app-status.mjs new file mode 100644 index 000000000..7b79a935b --- /dev/null +++ b/scripts/test-app-status.mjs @@ -0,0 +1,101 @@ +// An app's status in a sentence, and the one thing to press (lib/hosted-apps.ts appStatus): every branch, in the order +// it's decided; how many changes sit between live and the preview (changesBetween). Usage: node scripts/test-app-status.mjs +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, +}); +const { appStatus, changesBetween } = await import(pathToFileURL(`${root}/lib/hosted-apps.ts`).href); +const { reviewLabel, hasUnpublishedPreview } = await import(pathToFileURL(`${root}/lib/app-review.ts`).href); + +let passed = 0; +const check = (name, fn) => { + fn(); + passed++; + console.log(`ok - ${name}`); +}; +const base = { head: "h2", preview: { serving: "h2", state: "succeeded" }, live: { serving: "h1", state: "succeeded" }, domain: true, wasLive: true }; +const NO_DASHES = /[–—]| - /; + +check("its computer gone: a new home, by updating the preview", () => assert.deepEqual(appStatus({ ...base, lost: true }), { sentence: "Its computer is gone. Update the preview to give it a new home.", action: "update-preview", tone: "waits" })); +check("nothing committed: nothing built, nothing to press", () => assert.deepEqual(appStatus({ ...base, head: null }), { sentence: "Nothing built yet.", tone: "quiet" })); +check("a preview deploy on its way says so, and nothing to press", () => { + for (const state of ["queued", "running"]) assert.deepEqual(appStatus({ ...base, preview: { serving: "h1", state } }), { sentence: "Updating the preview…", tone: "working" }); +}); +check("a publish on its way: Publishing, or Pushing to GitHub", () => { + assert.deepEqual(appStatus({ ...base, live: { serving: "h1", state: "running" } }), { sentence: "Publishing…", tone: "working" }); + assert.deepEqual(appStatus({ ...base, live: { serving: "h1", state: "running" }, github: { linked: true } }), { sentence: "Pushing to GitHub…", tone: "working" }); +}); +check("a failed preview update or publish: said, and the same press offered again", () => { + assert.deepEqual(appStatus({ ...base, preview: { serving: "h1", state: "failed" } }), { sentence: "The last preview update failed.", action: "update-preview", tone: "failed" }); + assert.deepEqual(appStatus({ ...base, live: { serving: "h1", state: "failed" } }), { sentence: "The last publish failed.", action: "publish", tone: "failed" }); + assert.deepEqual(appStatus({ ...base, live: { serving: "h1", state: "failed" }, preview: null }), { sentence: "The last publish failed.", action: "update-preview", tone: "failed" }, "with no preview to publish from, the preview comes first"); +}); +check("a failed preview update with a preview that still serves something not live: Publish stays the press, said with the failure", () => { + const failed = { ...base, head: "h3", preview: { serving: "h2", state: "failed" }, live: { serving: "h1", state: "succeeded" }, changes: 1 }; + assert.deepEqual(appStatus(failed), { sentence: "The last preview update failed. What the preview still has can be published.", action: "publish", tone: "failed" }); + assert.equal(appStatus({ ...failed, github: { linked: true } }).action, "publish", "on GitHub, linked"); + assert.equal(appStatus({ ...failed, github: { linked: false } }).action, "update-preview", "not linked to GitHub: nothing to publish to"); + assert.equal(appStatus({ ...failed, domain: false, wasLive: false }).action, "update-preview", "nowhere to publish to"); + assert.equal(appStatus({ ...failed, preview: { serving: null, state: "failed" } }).action, "update-preview", "a preview serving nothing"); + assert.equal(reviewLabel(appStatus(failed)), "Preview update failed"); + assert.equal(hasUnpublishedPreview(appStatus(failed)), false, "a failure never borrows the pending review color"); +}); +check("a failed publish reads as one, whichever press comes next", () => { + assert.equal(reviewLabel(appStatus({ ...base, live: { serving: "h1", state: "failed" } })), "Publish failed"); + assert.equal(reviewLabel(appStatus({ ...base, live: { serving: "h1", state: "failed" }, preview: null })), "Publish failed", "even with the preview to update first"); + assert.equal(reviewLabel(appStatus({ ...base, preview: { serving: "h1", state: "failed" } })), "Preview update failed"); +}); +check("the preview behind the newest change (or none yet): Update preview", () => { + assert.deepEqual(appStatus({ ...base, preview: { serving: "h1", state: "succeeded" } }), { sentence: "The preview is behind the latest change.", action: "update-preview", tone: "waits" }); + assert.deepEqual(appStatus({ ...base, preview: null }), { sentence: "The preview is behind the latest change.", action: "update-preview", tone: "waits" }); +}); +check("an earlier version put back on the preview (Restore): Publish, to go back to it, never Update preview; a newer one than live, behind the head: Publish too", () => { + assert.deepEqual(appStatus({ ...base, head: "h3", preview: { serving: "h1", state: "succeeded" }, live: { serving: "h2", state: "succeeded" }, changes: 0, change: "First look" }), { sentence: "The preview has an earlier version than live.", detail: "First look", action: "publish", tone: "waits" }); + assert.deepEqual(appStatus({ ...base, head: "h3", preview: { serving: "h2", state: "succeeded" }, live: { serving: "h1", state: "succeeded" }, changes: 1 }), { sentence: "The preview has 1 change that isn't live.", action: "publish", tone: "waits" }); + assert.equal(appStatus({ ...base, head: "h3", preview: { serving: "h1", state: "succeeded" }, live: { serving: "h1", state: "succeeded" } }).action, "update-preview", "the preview at what's live, behind the head: Update preview"); + assert.equal(appStatus({ ...base, head: "h3", preview: { serving: "h1", state: "succeeded" }, live: { serving: "h2", state: "failed" }, changes: 0 }).action, "publish", "a failed publish with an earlier version on the preview: publish it again"); +}); +check("on GitHub but not linked: the link comes first (in Published's menu), nothing to press here", () => assert.deepEqual(appStatus({ ...base, github: { linked: false } }), { sentence: "The preview is ready. Link it to GitHub to publish.", tone: "waits" })); +check("preview and live the same: up to date; with no domain and never live, not published yet: Connect a domain", () => { + assert.deepEqual(appStatus({ ...base, live: { serving: "h2", state: "succeeded" } }), { sentence: "Live and up to date.", tone: "quiet" }); + assert.deepEqual(appStatus({ ...base, live: { serving: "h2", state: "succeeded" }, github: { linked: true }, domain: false, wasLive: false }), { sentence: "Live and up to date.", tone: "quiet" }); +}); +check("the preview ahead with no domain and never live: Connect a domain", () => assert.deepEqual(appStatus({ ...base, domain: false, wasLive: false }), { sentence: "The preview is ready. Connect a domain to publish it.", action: "connect-domain", tone: "waits" })); +check("the preview ahead of live: Publish, with the known count only and the newest change", () => { + assert.deepEqual(appStatus(base), { sentence: "The preview has changes that aren't live.", action: "publish", tone: "waits" }); + assert.deepEqual(appStatus({ ...base, changes: 3, change: "Home line" }), { sentence: "The preview has 3 changes that aren't live.", detail: "Home line", action: "publish", tone: "waits" }); + assert.deepEqual(appStatus({ ...base, changes: null }).sentence, "The preview has changes that aren't live."); + assert.deepEqual(appStatus({ ...base, domain: false, wasLive: true }).action, "publish", "live before with no domain publishes all the same"); + assert.deepEqual(appStatus({ ...base, domain: false, wasLive: false, github: { linked: true } }).action, "publish", "GitHub production needs no domain"); +}); +check("changesBetween: live and the preview in the history, by their distance; not known when either isn't there", () => { + const log = [{ id: "h4" }, { id: "h3" }, { id: "h2" }, { id: "h1" }]; + assert.equal(changesBetween(log, "h1", "h3"), 2); + assert.equal(changesBetween(log, "h2", "h2"), 0); + assert.equal(changesBetween(log, "h9", "h3"), null); + assert.equal(changesBetween(log, "h1", "h9"), null); + assert.equal(changesBetween(log, null, "h3"), null); + assert.equal(changesBetween(log, "h1", null), null); + assert.equal(changesBetween(log, "h3", "h1"), 0, "live ahead of the preview reads as nothing waiting"); +}); +check("no dashes in any sentence", () => { + const all = [appStatus(base), appStatus({ ...base, lost: true }), appStatus({ ...base, head: null }), appStatus({ ...base, github: { linked: false } }), appStatus({ ...base, domain: false, wasLive: false }), appStatus({ ...base, preview: { serving: "h2", state: "failed" } })]; + for (const s of all) assert.ok(!NO_DASHES.test(s.sentence), s.sentence); +}); +console.log(`${passed} passed`); diff --git a/scripts/test-hosted-apps-ui.mjs b/scripts/test-hosted-apps-ui.mjs index 17b87b746..834eb9a59 100644 --- a/scripts/test-hosted-apps-ui.mjs +++ b/scripts/test-hosted-apps-ui.mjs @@ -74,6 +74,11 @@ registerHooks({ return { format: "module", source: out.outputText, shortCircuit: true }; }, }); + +// Initialize React before the client graph: CJS icon imports can otherwise see a partial React module under Node hooks. +await import("react"); +await import("react/jsx-runtime"); + const emitWarning = process.emitWarning.bind(process); process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); @@ -96,6 +101,7 @@ globalThis.fetch = async (url, init = {}) => { return { ok: status < 400, status, json: async () => body }; }; +const AS = await import(pathToFileURL(`${root}/components/app/app-settings.tsx`).href); const H = await import(pathToFileURL(`${root}/components/app/hosted-apps.tsx`).href); const { MadeAppsHome } = await import(pathToFileURL(`${root}/components/app/made-apps.tsx`).href); const { NewTab, TabBar, isReading } = await import(pathToFileURL(`${root}/components/app/panel-tabs.tsx`).href); @@ -156,55 +162,21 @@ const deployIn = (part, label = "Deploy") => { const text = (html) => unescape(html.replace(/<[^>]+>/g, " ")); const list = (answer, extra = {}) => renderToStaticMarkup(h(H.HostedAppsList, { ws: "ws_main", answer, onChanged: noop, ...extra })); -await test("the list: one compact line per app with its state, name and address, then its Local, Staging and Prod: what Prod serves, Deploy on Staging", () => { +await test("the home lists apps with one review action and no deployment controls", () => { const html = list({ enabled: true, apps: APPS }); - assert.ok(html.includes(">On the web<"), "the list is titled"); - assert.deepEqual([...html.matchAll(/data-hosted-app="([^"]+)"/g)].map((m) => m[1]), APPS.map((a) => a.name), "one line per app, in the server's order"); - for (const a of APPS) assert.match(rowOf(html, a.name), /class="flex h-8 /, `${a.name}'s line is one 32px line`); - - const shop = rowOf(html, SHOP); - assert.match(shop, /data-state="succeeded"/); - assert.match(shop, new RegExp(`data-tip="Live · ${id("a1").slice(0, 7)} · 5m"[^>]*bg-\\[#2BB673\\]`), "a green dot that says it's live, which changeset and since when"); - assert.match(shop, new RegExp(`title="${SHOP}"[^>]*truncate`), "a long name is cut short, all of it in the tooltip"); - assert.match(shop, /]*>shop\.example<\/a>/, "its address (its domain) is a link that opens in the browser, shown without https"); - assert.ok(!html.includes(".orgo.dev"), "never an orgo.dev name for Published"); - assert.match(shop, new RegExp(`data-app-env="prod"[^]*data-app-env-at="true" title="${id("a1")}"[^>]*>${id("a1").slice(0, 7)}<`), "the short id Prod serves, all of it in the tooltip"); - assert.deepEqual(deployIn(shop), { tip: null, disabled: false }, "Deploy (to Staging) can be pressed"); - - const blog = rowOf(html, "blog"); - assert.match(blog, /data-state="running"/); - assert.match(blog, new RegExp(`data-tip="Deploying · ${id("b3").slice(0, 7)} · 5m"[^>]*animate-pulse`), "a pulsing dot while it deploys"); - assert.ok(blog.split("data-app-envs")[0].includes("Live, no domain yet"), "live before, with no domain: said so, never an orgo.dev name"); - assert.deepEqual(deployIn(blog), { tip: null, disabled: false }, "Staging can be deployed while Prod deploys"); - assert.deepEqual(deployIn(blog, "Publish"), { tip: "It's publishing. Wait for it to finish.", disabled: true }, "no second publish while one runs"); - assert.ok(blog.includes(`>${id("b2").slice(0, 7)}<`) && !blog.includes(`>${id("b3").slice(0, 7)}<`), "the one served, not the one still deploying"); - - const menu = rowOf(html, "menu"); - assert.match(menu, /data-state="failed"/); - assert.match(menu, new RegExp(`data-tip="Failed · ${id("c3").slice(0, 7)} · 5m: npm run build exited with 1"[^>]*bg-\\[#B42318\\]`), "a red dot whose tooltip says why it failed"); - assert.deepEqual(deployIn(menu), { tip: null, disabled: false }, "a failed one can be deployed again"); - assert.ok(menu.includes(`>${id("c2").slice(0, 7)}<`), "what's still served after one failed"); - - const draft = rowOf(html, "draft"); - assert.match(draft, /data-state="none"/); - assert.match(draft, /data-tip="Not on the web yet"/); - assert.ok(draft.includes("Not deployed yet") && !draft.includes(" { - const html = list({ enabled: false, why: "Sign in to Orgo to deploy.", apps: APPS }); - for (const a of APPS) assert.deepEqual(deployIn(rowOf(html, a.name)), { tip: "Sign in to Orgo to deploy.", disabled: true }, a.name); - assert.ok(rowOf(html, SHOP).includes("shop.example"), "the apps are still listed, with their addresses"); - const vague = list({ enabled: false, apps: [APPS[0]] }); - assert.equal(deployIn(rowOf(vague, SHOP)).tip, "Deploying isn't open here.", "a reason when the server gave none"); + assert.match(html, />Your Appsm[1]), APPS.map(a=>a.name)); + for(const a of APPS) { const row=rowOf(html,a.name); assert.ok(row.includes(`aria-label="Open ${a.name}"`)); assert.match(row, /Open app|Review changes/); } + assert.ok(!html.includes("data-app-env") && !html.includes("data-act=")); + assert.ok(!NO_DASHES.test(text(html))); +}); + +await test("closed hosting keeps apps reviewable and prevents Add app", () => { + const html=list({enabled:false,why:"Sign in to Orgo to deploy.",apps:APPS}); + assert.match(html,/data-add-app="true" disabled=""/); + assert.match(html,/data-tip="Sign in to Orgo to deploy."/); + for(const a of APPS) assert.ok(rowOf(html,a.name).includes(`Open ${a.name}`)); + assert.ok(!html.includes("data-act=")); }); await test("the list with none, before it's read, and when it couldn't be", () => { @@ -213,9 +185,14 @@ await test("the list with none, before it's read, and when it couldn't be", () = assert.match(none, /data-no-apps[^]*data-add-app="true"[^>]*>.*Add app<\/button>/, "Add app beside it"); assert.equal(none.match(/data-add-app="true"/g).length, 1, "once: not in the title too while there are none"); assert.ok(!none.includes("data-hosted-app=")); - assert.equal(list({ enabled: false, why: "Hosting apps on the web isn't open to your Orgo account yet.", apps: [] }), "", "nothing at all where none can go on the web"); + // None, and hosting isn't open here: no invitation to make a site that can't go on the web (no Add app), only why, + // quietly; the tools still listed. + const closed = list({ enabled: false, why: "Hosting apps on the web isn't open to your Orgo account yet.", apps: [] }, { tools: h("div", { "data-tool-row": "" }, "A tool") }); + assert.ok(!closed.includes("data-no-apps") && !closed.includes("data-add-app") && !closed.includes("Ask a bot"), "no invitation where hosting is closed"); + assert.match(closed, /data-apps-closed[^>]*>Hosting apps on the web isn't open to your Orgo account yet\.Your Apps<"), "the title and the tools stay"); const reading = list(null); - assert.ok(reading.includes(">On the web<") && !reading.includes("Ask a bot") && !reading.includes("Couldn"), "nothing but the title while it's read"); + assert.ok(reading.includes(">Your Apps<") && !reading.includes("Ask a bot") && !reading.includes("Couldn"), "nothing but the title while it's read"); assert.ok(list(null, { failed: true }).includes("Couldn't read them just now."), "a quiet line when it couldn't be read"); }); @@ -244,86 +221,21 @@ const commitOf = (html, c) => { }; const shopApp = (extra = {}) => app("shop", { serving: id("c3"), live: live("succeeded", id("c3"), { url: "https://shop.example" }), domain: "https://shop.example", domains: [{ domain: "shop.example", status: "active" }], ...extra }); -await test("the tab's header: its name, its address (open, copy), Work on it and Deploy to Staging", () => { - const top = header(tab(shopApp(), { work: { name: "Nova", go: noop } })); - assert.match(top, /title="shop"[^>]*truncate[^>]*>shop]*>shop\.example<\/a>/, "its address (its domain) opens in the browser"); - assert.match(top, /aria-label="Copy link"/, "and can be copied"); - assert.match(top, new RegExp(`data-tip="Live · ${id("c3").slice(0, 7)} · 5m"`), "its state, in its dot"); - assert.match(top, /data-tip="Opens Nova's chat with this typed, not sent"[^>]*>Work on itWork on it<") < top.indexOf(">Deploy preview<"), "before Deploy"); - assert.deepEqual(deployIn(top, "Deploy preview"), { tip: null, disabled: false }, "Deploy (the newest changeset, to Staging)"); - assert.ok(!header(tab(shopApp())).includes("Work on it"), "no Work on it without a bot to ask"); -}); - -await test("the tab's history: one compact line per changeset, the one served marked Live, Deploy this one on the others", () => { - const html = tab(shopApp()); - assert.ok(html.includes(">History<") && !html.includes("data-offline"), "the history"); - assert.deepEqual([...html.matchAll(/data-commit="([^"]+)"/g)].map((m) => m[1]), COMMITS.map((c) => c.id), "every changeset, newest first"); - for (const c of COMMITS) assert.match(commitOf(html, c.id), /class="group flex h-8 /, `${c.id.slice(0, 7)} is one 32px line`); - - const cart = commitOf(html, id("e5")); - assert.match(cart, new RegExp(`title="${id("e5")}"[^>]*>${id("e5").slice(0, 7)}<`), "its short id, all of it in the tooltip"); - assert.match(cart, /title="Adds the cart\n\nIt keeps what you picked while you look around\."[^>]*truncate[^>]*>Adds the cartTess Ito<") && cart.includes(">1m<"), "who and when"); - assert.match(cart, /data-changes="true" title="3 files: 2 added, 1 changed"[^>]*>(]*>\+2<\/span>|]*>\+2<\/span>)/, "files added and changed, in words in the tooltip"); - assert.ok(!/>-0<|>-0]*>Menu prices for every dish, with a long message/, "a long message on one line, cut short"); - assert.match(prices, /title="4 files: 3 changed, 1 deleted"/); - - const served = commitOf(html, id("c3")); - assert.ok(served.includes("data-live") && served.includes(">Published<") && served.includes('data-serving="true"'), "the one its site serves is marked Published"); - assert.ok(!deployIn(served, "Preview this one"), "and isn't offered again"); - assert.equal([...html.matchAll(/data-live/g)].length, 1, "one live"); - for (const c of [id("d4"), id("b2"), id("a1")]) assert.deepEqual(deployIn(commitOf(html, c), "Preview this one"), { tip: null, disabled: false }, `${c.slice(0, 7)} can be deployed`); - assert.match(commitOf(html, id("a1")), /opacity-0[^"]*group-hover:opacity-100[^>]*>]*>]*>Preview this one]*>"), "its title's Deploy, as ever (to Staging)"); }); await test("one site's domains as its tab read them go on its line (a domain added, removed or gone live), the list as it was when they're what it says, and an app with one site untouched", () => { @@ -1919,26 +1785,16 @@ await test("one site's domains as its tab read them go on its line (a domain add assert.deepEqual(H.siteDomainsOf({ url: null, zones: [], domains: [{ id: "cd_1", domain: "a.example.com", status: "active", cert: "issued", managed: false, zone: null, publish: [] }] }), [{ domain: "a.example.com", status: "active" }]); }); -await test("its end, under the history: Add a site, Remove the site picked (not its own), Remove app; an app whose computer is gone says it needs a new home", () => { - const end = between(suiteTab({ site: "suite-admin", onSite: noop }), "data-app-end", ""); - assert.ok(end.includes(">Add a site<") && end.includes(">Remove suite-admin<") && end.includes(">Remove app<")); - const own = between(suiteTab(), "data-app-end", ""); - assert.ok(!own.includes(">Remove suite<"), "its own site goes only with the app"); - const one = tab(shopApp()); - assert.ok(one.indexOf("data-app-end") > one.indexOf(">History<"), "at the end"); - const full = between(suiteTab({ app: suiteApp({ sites: [...SUITE_SITES, { ...SUITE_SITES[2], name: "suite-d" }, { ...SUITE_SITES[2], name: "suite-e" }] }) }), "data-app-end", ""); - assert.match(full, /data-tip="An app can have 5 sites at most\."[^>]*>
", start) + 12); }; -const envsOf = (props) => renderToStaticMarkup(h(E.AppEnvs, { ws: "ws_main", answer: { enabled: true }, sites: [props.app], local: null, onLocal: noop, onChanged: noop, ...props })); - -await test("the three environments, one line each, Local, Preview and Published: each its state in a word, where it is, the changeset it serves, and its actions; Published never an orgo.dev name", () => { - const html = envsOf({ app: shopApp({ preview: STAGING() }), local: LOCAL }); - assert.deepEqual([...html.matchAll(/data-app-env="([a-z]+)"/g)].map((m) => m[1]), ["local", "preview", "prod"], "in that order"); - assert.ok(html.includes(">Local, Preview and Published<"), "its heading"); - const local = envLine(html, "local"); - assert.ok(local.includes(">Local<") && local.includes('data-app-env-state="Running"') && local.includes(">localhost:3000<"), "Local: running, seen at localhost:3000"); - assert.ok(local.includes(">Open preview<") && local.includes(">Stop<"), "Open preview and Stop while it runs"); - // Its address is its port, the same on its computer: one an older bops-local started elsewhere shows where it runs. - const moved = envLine(envsOf({ app: shopApp(), local: { sites: { shop: { ...LOCAL.sites.shop, port: 3003 } } } }), "local"); - assert.ok(moved.includes(">localhost:3003<") && !moved.includes("is taken there"), "its own port, the address everywhere"); - const elsewhere = envLine(envsOf({ app: shopApp(), local: { sites: { shop: { ...LOCAL.sites.shop, state: "starting", port: 3001, devPort: 3001, mismatch: 5173 } } } }), "local"); - assert.ok(elsewhere.includes(">localhost:3001<") && elsewhere.includes('data-tip="Its dev server listens on 5173 instead of 3001. Make its command use $PORT."'), "a dev server on another port, said, never taken for its address"); - // Failed: its last line in the tooltip (error keeps its log's last lines, for Fix it). - assert.equal(E.localState({ ...LOCAL.sites.shop, state: "failed", error: "> next dev\nError: Cannot find module 'next'" }, LOCAL).tip, "It stopped: Error: Cannot find module 'next'"); - // An app with more sites, on the Apps page: each running site's own address. - const two = { sites: { shop: LOCAL.sites.shop, admin: { ...LOCAL.sites.shop, port: 3001, devPort: 3001 } } }; - const both = envLine(renderToStaticMarkup(h(E.AppEnvs, { ws: "ws_main", answer: { enabled: true }, app: shopApp(), sites: [shopApp(), { ...shopApp(), name: "admin" }], local: two, onLocal: noop, onChanged: noop })), "local"); - assert.ok(both.includes(">localhost:3000, :3001<"), "each site's address, the others by their port"); - assert.ok(both.includes('data-tip="shop at localhost:3000, admin at localhost:3001"'), "and whose is which, on hover"); - const preview = envLine(html, "preview"); - assert.ok(preview.includes(">Preview<") && preview.includes('data-app-env-state="Running"') && preview.includes(">k7p2qxm4wr3d.orgo.dev<") && preview.includes(`>${id("e5").slice(0, 7)}<`), "Preview: its fixed link and what it serves"); - assert.deepEqual(deployIn(preview), { tip: null, disabled: false }, "Deploy"); - const prod = envLine(html, "prod"); - assert.ok(prod.includes(">Published<") && prod.includes(">shop.example<") && prod.includes(`>${id("c3").slice(0, 7)}<`), "Published: its domain and what it serves"); - assert.ok(!prod.includes(".orgo.dev"), "never an orgo.dev name"); - assert.deepEqual(deployIn(prod, "Publish"), { tip: null, disabled: false }, "Publish: the preview serves another changeset"); - assert.match(prod, /data-prod-menu="true" aria-label="More for Published" aria-haspopup="menu" aria-expanded="false"/, "Roll back, Apply variables and Domain in its menu"); - // Publish waits for something to publish, and for Published. - const none = envLine(envsOf({ app: shopApp() }), "prod"); - assert.deepEqual(deployIn(none, "Publish"), { tip: "The preview isn't live yet. Deploy a preview first.", disabled: true }); - const same = envLine(envsOf({ app: shopApp({ preview: STAGING({ serving: id("c3") }) }) }), "prod"); - assert.deepEqual(deployIn(same, "Publish"), { tip: "What's published is the preview already.", disabled: true }); - const busy = envLine(envsOf({ app: shopApp({ preview: STAGING(), live: live("running", id("e5")) }) }), "prod"); - assert.deepEqual(deployIn(busy, "Publish"), { tip: "It's publishing. Wait for it to finish.", disabled: true }); - assert.match(busy, /data-app-env-state="Deploying"/); - // No domain: never live, Not published with Connect a domain; pending, waiting for DNS with Publish; live before, Live, no domain yet. - const fresh = envLine(envsOf({ app: app("fresh", { serving: null, live: null, preview: STAGING() }) }), "prod"); - assert.ok(fresh.includes('data-app-env-state="Not published"') && fresh.includes(">No domain yet<") && fresh.includes(">Connect a domain<"), "never live, no domain"); - const waiting = envLine(envsOf({ app: app("fresh", { serving: null, live: null, preview: STAGING(), domains: [{ domain: "fresh.example", status: "pending" }] }) }), "prod"); - assert.ok(waiting.includes(">fresh.example, waiting for DNS<") && deployIn(waiting, "Publish")?.disabled === false, "a pending domain is enough to publish"); - const legacy = envLine(envsOf({ app: shopApp({ preview: STAGING(), domain: null, domains: [] }) }), "prod"); - assert.ok(legacy.includes('data-app-env-state="Running"') && legacy.includes(">Live, no domain yet<") && deployIn(legacy, "Publish")?.disabled === false, "live before: publishes without a domain"); - // Each state's word. - const failed = envLine(envsOf({ app: shopApp({ preview: STAGING({ live: live("failed", id("e5"), { error: "npm run build exited with 1" }) }) }) }), "preview"); - assert.match(failed, /data-tip="Its last deploy failed: npm run build exited with 1"[^>]*bg-\[#B42318\]/); - assert.match(failed, /data-app-env-state="Failed"/); - assert.match(envLine(envsOf({ app: shopApp() }), "preview"), /data-app-env-state="Not deployed yet"[^]*Gets a link of its own on its first deploy/); - const off = envLine(envsOf({ app: shopApp({ preview: STAGING({ off: true }) }) }), "preview"); - assert.ok(off.includes('data-app-env-state="Off"') && off.includes('data-tip="Orgo turned its link off."') && !off.includes('href="https://k7p2qxm4wr3d.orgo.dev"'), "Off: its link not clickable"); - const words = (local) => envLine(envsOf({ app: shopApp(), local }), "local").match(/data-app-env-state="([^"]+)"/)[1]; - assert.deepEqual( - [words(null), words({ sites: {} }), words({ sites: {}, asleep: true }), words({ sites: { shop: { ...LOCAL.sites.shop, state: "starting" } } }), words({ sites: { shop: { ...LOCAL.sites.shop, state: "failed", error: "Cannot find module next" } } })], - ["Reading…", "Stopped", "Stopped", "Starting", "Failed"], - ); - assert.match(envLine(envsOf({ app: shopApp(), local: { sites: {}, asleep: true } }), "local"), /data-tip="Its computer is asleep, so nothing runs there\. Start wakes it\."/); - assert.ok(envLine(envsOf({ app: shopApp(), local: { sites: {} } }), "local").includes(">Runs its working folder with hot reload<")); - assert.ok(!NO_DASHES.test(text(html)) && !/orbit/i.test(text(html)), "plain words"); -}); - -await test("the Apps page: every app with its three lines under it; the lines of an app with more sites say all of them", () => { - const html = list({ enabled: true, apps: [shopApp({ preview: STAGING() }), suiteApp()] }); - const shop = rowOf(html, "shop"); - assert.deepEqual([...shop.matchAll(/data-app-env="([a-z]+)"/g)].map((m) => m[1]), ["local", "preview", "prod"]); - assert.ok(shop.indexOf("data-app-envs") > shop.indexOf(">shop<"), "under its line"); - const suite = rowOf(html, "suite"); - assert.match(envLine(suite, "prod"), /data-app-env-state="Failed"/, "one of its sites failed"); - assert.match(envLine(suite, "preview"), /data-app-env-state="Not deployed yet"/); +const envsOf = async (props) => { const m=await mount(h(E.AppEnvs,{ws:"ws_main",answer:{enabled:true},sites:[props.app],local:null,onLocal:noop,onChanged:noop,...props})); const html=m.box.innerHTML; await m.done(); return html; }; + +await test("technical details retain Local, Preview, live destination, and deployment guards", async () => { + const a=shopApp({preview:STAGING()}); + const html=await envsOf({app:a,local:LOCAL}); + assert.deepEqual([...html.matchAll(/data-app-env="([a-z]+)"/g)].map(m=>m[1]),["local","preview","prod"]); + assert.ok(envLine(html,"local").includes("localhost:3000")); + assert.ok(envLine(html,"local").includes("Open working copy") && envLine(html,"local").includes("Stop")); + assert.ok(envLine(html,"preview").includes("k7p2qxm4wr3d.orgo.dev")); + assert.ok(envLine(html,"prod").includes("shop.example") && !envLine(html,"prod").includes(".orgo.dev")); + assert.equal(E.whyNotEnv({enabled:true},a,"prod",[a]),undefined); + assert.match(E.whyNotEnv({enabled:false,why:"Closed"},a,"preview",[a]),/Closed/); + const none=shopApp(); assert.match(E.whyNotEnv({enabled:true},none,"prod",[none]),/preview isn't live/); + const current=shopApp({preview:STAGING({serving:id("c3")})}); assert.match(E.whyNotEnv({enabled:true},current,"prod",[current]),/already/); + const going=shopApp({preview:STAGING(),live:live("running",id("e5"))}); assert.match(E.whyNotEnv({enabled:true},going,"prod",[going]),/publishing/); + const domain=shopApp({preview:STAGING(),serving:null,live:null,domain:null,domains:[]}); assert.match(E.whyNotEnv({enabled:true},domain,"prod",[domain]),/Connect a domain/); + assert.equal(E.localState(LOCAL.sites.shop,LOCAL).word,"Running"); + assert.equal(E.localState(undefined,{sites:{},asleep:true}).word,"Stopped"); + assert.equal(E.localState({...LOCAL.sites.shop,state:"starting"},LOCAL).word,"Starting"); + assert.match(E.localState({...LOCAL.sites.shop,state:"failed",error:"> next dev\nError: Cannot find module 'next'"},LOCAL).tip,/Cannot find module/); +}); + +await test("home rows keep environment controls out of the review entry point", () => { + const html=list({enabled:true,apps:[shopApp({preview:STAGING()}),suiteApp()]}); + assert.equal((html.match(/data-hosted-app="/g)??[]).length,2); + assert.ok(!html.includes("data-app-env=")); assert.match(html,/Review changes|Open app/); }); await test("more bots on one app: a Local line for each bot's copy, named for its bot (its own folder's too, named for its own bot), each with its own address and Start, Open preview and Stop, which name that bot", async () => { @@ -3106,14 +2908,15 @@ await test("more bots on one app: a Local line for each bot's copy, named for it { bot: "kim", name: "Kim", sites: {} }, ], }; - const html = envsOf({ app: shopApp({ preview: STAGING() }), local: COPIES }); - assert.deepEqual([...html.matchAll(/data-app-env="([a-z]+)"(?: data-app-env-bot="([a-z]+)")?/g)].map((m) => m[2] ?? m[1]), ["local", "sam", "kim", "preview", "prod"], "its own folder's first, then each copy's, then Preview and Published"); - const lines = html.split('data-app-env="').slice(1, 4); + const html = await envsOf({ app: shopApp({ preview: STAGING() }), local: COPIES }); + const copyDoc=parseHTML(`${html}`).document; + assert.deepEqual([...copyDoc.querySelectorAll('[data-app-env]')].map(x=>x.getAttribute('data-app-env-bot')??x.getAttribute('data-app-env')),["local","sam","kim","preview","prod"]); + const lines = [...copyDoc.querySelectorAll("[data-app-env]")].slice(0,3).map(x=>x.outerHTML); assert.deepEqual(lines.map((l) => /data-local-bot="true"[^>]*>([^<]+)localhost:3000<") && lines[1].includes(">localhost:3001<"), "each its own address"); assert.ok(lines[2].includes('data-app-env-state="Stopped"') && lines[2].includes(">Its own copy of the code<") && buttonsIn(lines[2]).includes("Start"), "one not started: Start"); - assert.deepEqual([buttonsIn(lines[0]), buttonsIn(lines[1])], [["Open preview", "Stop"], ["Open preview", "Stop"]]); - assert.ok(!envsOf({ app: shopApp(), local: LOCAL }).includes("data-local-bot"), "one bot alone: no names, as before"); + assert.deepEqual([buttonsIn(lines[0]), buttonsIn(lines[1])], [["Open working copy", "Stop"], ["Open working copy", "Stop"]]); + assert.ok(!(await envsOf({ app: shopApp(), local: LOCAL })).includes("data-local-bot"), "one bot alone: no names, as before"); assert.ok(!NO_DASHES.test(text(html)), "plain words"); // Clicked: each line's acts name its bot; Open preview opens that bot's copy beside its chat. const { OpenPreview } = await import(pathToFileURL(`${root}/components/app/panel-tabs.tsx`).href); @@ -3126,8 +2929,8 @@ await test("more bots on one app: a Local line for each bot's copy, named for it await click(button(m.box.querySelector('[data-app-env-bot="sam"]'), "Stop")); await click(button(m.box.querySelector('[data-app-env="local"]:not([data-app-env-bot])'), "Stop")); assert.deepEqual(posts(n, "/api/hosted-apps/shop/local").map((c) => c.body), [{ ws: "ws_copies", action: "start", bot: "kim" }, { ws: "ws_copies", action: "stop", bot: "sam" }, { ws: "ws_copies", action: "stop" }]); - await click(button(m.box.querySelector('[data-app-env-bot="sam"]'), "Open preview")); - await click(button(m.box.querySelector('[data-app-env="local"]:not([data-app-env-bot])'), "Open preview")); + await click(button(m.box.querySelector('[data-app-env-bot="sam"]'), "Open working copy")); + await click(button(m.box.querySelector('[data-app-env="local"]:not([data-app-env-bot])'), "Open working copy")); assert.deepEqual(asked, [["ws_copies", "shop", undefined, "sam"], ["ws_copies", "shop", undefined, undefined]]); } finally { await m.done(); @@ -3142,64 +2945,11 @@ const domOf = (html) => { }; const classes = (el) => (el?.getAttribute("class") ?? "").split(/\s+/).filter(Boolean); -await test("narrow or wide, nothing on a line drawn over anything: a row of columns once its panel is wide, else its place, what it serves and its actions on a first line and its state and where it is wrapping under its name; anything cut short whole in its tooltip; a bot's copy's line and its own folder's say whose they are", () => { - const COPIES = { sites: LOCAL.sites, bot: { id: "nova", name: "Nova" }, copies: [{ bot: "sam", name: "Sam", sites: { shop: { ...LOCAL.sites.shop, port: 3001, devPort: 3001 } } }, { bot: "kim", name: "Kim", sites: {} }] }; - const listed = (props) => envsOf({ compact: true, ...props }); - const kinds = { - "with copies": listed({ app: shopApp({ preview: STAGING() }), local: COPIES }), - "its tab, with copies": envsOf({ app: shopApp({ preview: STAGING() }), local: COPIES, picked: "prod", onPick: noop }), - "one bot": listed({ app: shopApp({ preview: STAGING() }), local: LOCAL }), - "live, no domain yet": listed({ app: shopApp({ preview: STAGING(), domain: null, domains: [] }), local: { sites: {} } }), - "waiting for DNS": listed({ app: app("fresh", { serving: null, live: null, preview: STAGING(), domains: [{ domain: "fresh.example", status: "pending" }] }) }), - "its link off": listed({ app: shopApp({ preview: STAGING({ off: true }) }) }), - "needs a new home": listed({ app: app("gone", { hosting: { botId: "nova", computerId: "c9", lost: 1 } }) }), - "more sites": renderToStaticMarkup(h(E.AppEnvs, { compact: true, ws: "ws_main", answer: { enabled: true }, app: shopApp(), sites: [shopApp(), { ...shopApp(), name: "admin" }], local: { sites: { shop: LOCAL.sites.shop, admin: { ...LOCAL.sites.shop, port: 3001, devPort: 3001 } } }, onLocal: noop, onChanged: noop })), - }; - for (const [kind, html] of Object.entries(kinds)) { - const box = domOf(html); - assert.ok(classes(box.querySelector("[data-app-envs]")).includes("@container"), `${kind}: its lines read the width they have, not the window's`); - assert.ok(![...box.querySelectorAll("[class]")].some((el) => classes(el).some((c) => /^(?:@[\w-]+:)?(?:absolute|fixed)$/.test(c))), `${kind}: nothing placed over another column`); - const lines = [...box.querySelectorAll("[data-app-env]")]; - const wide = lines.some((l) => l.querySelector("[data-local-bot]")) ? "@2xl:w-[134px]" : "@2xl:w-[84px]"; - for (const line of lines) { - const is = `${kind}, ${line.getAttribute("data-app-env")}${line.getAttribute("data-app-env-bot") ? ` of ${line.getAttribute("data-app-env-bot")}` : ""}`; - const place = line.querySelector("[data-app-env-place]"); - const where = line.querySelector("[data-app-env-where]"); - const at = line.querySelector("[data-app-env-at]"); - // Its pieces side by side on a line that wraps, none inside another: its place, its state and where it is, what it serves, its actions. - assert.ok(classes(line).includes("flex-wrap") && !classes(line).some((c) => /^h-\d/.test(c)), `${is}: a line that wraps, never a fixed height`); - assert.deepEqual([...line.children].slice(0, at ? 3 : 2), at ? [place, where, at] : [place, where], `${is}: its pieces in order`); - // Narrow, its place takes what's left of the first line; wide, a column as wide on every line, so their states line up. - for (const c of ["min-w-0", "flex-1", "@2xl:flex-none", wide]) assert.ok(classes(place).includes(c), `${is}: its place, ${c}`); - // Narrow, its state and where it is wrap on a line of their own under its name; wide, a column between its name and what it serves. - for (const c of ["order-last", "basis-full", "flex-wrap", "min-w-0", "pl-3.5", "@2xl:order-none", "@2xl:basis-0", "@2xl:grow", "@2xl:flex-nowrap", "@2xl:pl-0"]) assert.ok(classes(where).includes(c), `${is}: where it is, ${c}`); - const state = where.firstElementChild; - assert.equal(state.textContent, line.getAttribute("data-app-env-state"), `${is}: its state first`); - assert.ok(classes(state).includes("@2xl:w-[112px]") && classes(state).includes("@2xl:shrink-0"), `${is}: its state a column when wide`); - if (at) assert.ok(classes(at).includes("shrink-0") && at.getAttribute("title"), `${is}: what it serves, whole, all of it in its tooltip`); - // Anything that can be cut short can shrink, and has all of it in its tooltip. - for (const el of line.querySelectorAll("[class]")) { - if (!classes(el).includes("truncate")) continue; - assert.ok(classes(el).includes("min-w-0"), `${is}: "${el.textContent}" shrinks`); - assert.ok(el.getAttribute("title") || el.getAttribute("data-tip"), `${is}: "${el.textContent}" in its tooltip`); - } - // Its words cut short show whole in their tooltips (a state's own tooltip says more of it). - for (const el of where.querySelectorAll("[data-tip]")) if (el !== state && !el.hasAttribute("data-local-where")) assert.equal(el.getAttribute("data-tip"), el.textContent, `${is}: "${el.textContent}"`); - } - } - // With more bots: each Local line says whose it is in its place (Local · Nova), its own folder's too; a copy's note where it is. - const box = domOf(kinds["with copies"]); - const said = (el) => text(el.innerHTML).replace(/\s+/g, " ").trim(); - const locals = [...box.querySelectorAll('[data-app-env="local"]')]; - assert.deepEqual(locals.map((l) => said(l.querySelector("[data-app-env-place]"))), ["Local · Nova", "Local · Sam", "Local · Kim"]); - assert.deepEqual(locals.map((l) => l.querySelector("[data-app-env-place] [data-local-bot]").getAttribute("title")), ["Nova", "Sam", "Kim"], "each name whole in its tooltip"); - assert.ok(locals.every((l) => !l.querySelector("[data-app-env-where] [data-local-bot]")), "never squeezed in with where it is"); - assert.deepEqual(locals.map((l) => said(l.querySelector("[data-app-env-where]"))), ["Running localhost:3000", "Running localhost:3001", "Stopped Its own copy of the code"]); - assert.deepEqual(locals.map((l) => buttonsIn(l.outerHTML)), [["Open preview", "Stop"], ["Open preview", "Stop"], ["Start"]], "its actions on its first line"); - // The Preview line: its link where it is, what it serves a piece of its own after it. - const preview = box.querySelector('[data-app-env="preview"]'); - assert.ok(preview.querySelector('[data-app-env-where] a[title="https://k7p2qxm4wr3d.orgo.dev"]') && preview.querySelector("[data-app-env-at]").parentNode === preview, "the link and the changeset apart"); - assert.ok(!NO_DASHES.test(said(box)) && !/orbit/i.test(said(box)), "plain words"); +await test("technical actions use wrapping rows and consistent icon sizing", async () => { + const html=await envsOf({app:shopApp({preview:STAGING()}),local:LOCAL}); + const {document:d}=parseHTML(`${html}`); + for(const line of d.querySelectorAll('[data-app-env]')) assert.ok(line.className.includes("flex-wrap")); + for(const icon of d.querySelectorAll('svg.lucide')) { assert.equal(icon.getAttribute('width'),'16'); assert.equal(icon.getAttribute('height'),'16'); assert.equal(icon.getAttribute('stroke-width'),'1.5'); } }); await test("clicked: Deploy sends to Staging, Promote to Prod sends what Staging serves, Local's Start and Stop go to its computer, Roll back asks first then puts Prod's earlier deploy back, Domain opens the app's tab at Add a domain", async () => { @@ -3215,10 +2965,11 @@ await test("clicked: Deploy sends to Staging, Promote to Prod sends what Staging ); try { let n = calls.length; - await click(button(m.box.querySelector('[data-app-env="preview"]'), "Deploy")); + await click(button(m.box.querySelector('[data-app-env="preview"]'), "Update preview")); assert.deepEqual(posts(n, "/api/hosted-apps/shop").map((c) => c.body), [{ ws: "ws_click", action: "deploy", env: "preview" }]); n = calls.length; - await click(button(m.box.querySelector('[data-app-env="prod"]'), "Publish")); + await click(button(m.box, "Publish changes")); + await click(button(m.box.querySelector("[data-publish-confirmation]"), "Publish changes")); assert.deepEqual(posts(n, "/api/hosted-apps/shop").map((c) => c.body), [{ ws: "ws_click", action: "promote", site: "shop", at: id("e5") }], "with the changeset the window saw on its preview"); assert.ok(m.box.textContent.includes("shop-admin has no preview live"), "what it said"); n = calls.length; @@ -3258,13 +3009,13 @@ await test("its tab: the three lines under its title, a line pressed picks what await act(settle); try { const tab = () => m.box.querySelector("[data-hosted-tab]"); - assert.ok(tab().innerHTML.indexOf("data-app-envs") < tab().innerHTML.indexOf("data-address-section"), "the three lines above the sections"); - assert.ok(m.box.querySelector('[data-app-env="prod"][data-app-env-picked="true"]'), "Prod picked at first"); + assert.ok(button(m.box,"Technical settings")); + assert.equal(button(m.box,"Live").getAttribute("aria-pressed"),"true"); assert.ok(m.box.querySelector("[data-domains]"), "Prod's domains"); - assert.ok(tab().querySelector('[data-commit="' + id("e5") + '"] [data-on-preview]'), "the history marks what Staging serves"); + assert.ok(!tab().querySelector("[data-history]"),"history belongs to the review screen"); // Staging. let n = calls.length; - await click(m.box.querySelector('[data-app-env="preview"] button[aria-pressed]')); + await click(button(m.box,"Preview")); await act(settle); assert.ok(calls.slice(n).some((c) => c.url.includes("/settings?") && c.url.includes("env=preview")), "its settings read with env=preview"); assert.ok(!m.box.querySelector("[data-domains]"), "no domains on Staging"); @@ -3279,7 +3030,7 @@ await test("its tab: the three lines under its title, a line pressed picks what await submit(f); assert.deepEqual(posts(n, "/api/hosted-apps/shop/env").map((c) => [c.body.where, c.body.env]), [["preview", { SENTRY_DSN: "dsn-for-staging" }]], "as the Preview's"); // Local. - await click(m.box.querySelector('[data-app-env="local"] button[aria-pressed]')); + await click(button(m.box,"Local")); await act(settle); const local = m.box.querySelector("[data-local-section]"); assert.ok(local && local.textContent.includes("Runs its working folder with hot reload, at localhost:3000 here and on its computer."), "Local's section"); @@ -3337,12 +3088,12 @@ await test("Open preview: the app's preview beside its bot's chat (bops-app.tsx const a = await mount(view(LOCAL)); try { const n = calls.length; - await click(button(a.box.querySelector('[data-app-env="local"]'), "Open preview")); + await click(button(a.box.querySelector('[data-app-env="local"]'), "Open working copy")); await act(settle); assert.deepEqual(asked, [["ws_prev", "shop", undefined]], "the app's preview, beside its bot's chat"); assert.equal(calls.length, n, "nothing more asked from here"); answer = { screen: true, said: "This Mac can't reach its computer, so it opens on its screen." }; - await click(button(a.box.querySelector('[data-app-env="local"]'), "Open preview")); + await click(button(a.box.querySelector('[data-app-env="local"]'), "Open working copy")); await act(settle); assert.ok(a.box.textContent.includes("This Mac can't reach its computer, so it opens on its screen."), "said under the line"); } finally { @@ -3351,7 +3102,7 @@ await test("Open preview: the app's preview beside its bot's chat (bops-app.tsx const b = await mount(view(LOCAL, false)); try { const n = calls.length; - await click(button(b.box.querySelector('[data-app-env="local"]'), "Open preview")); + await click(button(b.box.querySelector('[data-app-env="local"]'), "Open working copy")); assert.deepEqual(posts(n, "/api/hosted-apps/shop/local").map((c) => c.body), [{ ws: "ws_prev", action: "open" }], "on its computer's screen"); assert.deepEqual(bots, [["nova", 101]], "and the panel shows that computer at the screen it opened on"); } finally { @@ -3359,7 +3110,7 @@ await test("Open preview: the app's preview beside its bot's chat (bops-app.tsx } }); -await test("the Preview tab's wiring (bops-app.tsx): its bot and chat, the owner's own only, its links read as the apps change, On its screen at the screen the server said; the composer bridge around the chat and the panel, and the message box that takes it", () => { +await test("the Preview tab's wiring (bops-app.tsx): its bot and chat, the owner's own only, its links read as the apps change, On its screen at the screen the server said; the composer bridge around the chat and the panel, and the message box that takes it", async () => { const src = readFileSync(`${root}/components/app/bops-app.tsx`, "utf8"); const tab = src.slice(src.indexOf("", src.indexOf(" { +await test("production on GitHub (AppHosting.github): Published says where on GitHub and at which commit, its button pushes there, linked first, and never asks for a domain", async () => { const gh = (last) => ({ repo: "OrgoAI/orgo-brandlord", branch: "main", path: "formula", ...(last ? { last } : {}) }); const on = (github, extra = {}) => shopApp({ preview: STAGING(), serving: null, live: null, domain: null, domains: [], hosting: { ...hosting("shop"), github }, ...extra }); - const unlinked = envLine(envsOf({ app: on(gh()) }), "prod"); - assert.ok(unlinked.includes("GitHub OrgoAI/orgo-brandlord main, formula/, not linked yet"), "where, not linked yet"); - assert.ok(unlinked.includes('data-app-env-state="Not linked"')); - assert.deepEqual(deployIn(unlinked, "Publish to GitHub"), { tip: "Link it to GitHub first, in the menu beside Publish.", disabled: true }); - assert.ok(!unlinked.includes("Connect a domain"), "no domain asked for: GitHub deploys it"); - const linked = envLine(envsOf({ app: on(gh({ ingot: id("c1"), sha: "a".repeat(40), when: min(5) })) }), "prod"); - assert.ok(linked.includes("GitHub OrgoAI/orgo-brandlord main, formula/, at aaaaaaa") && linked.includes('data-app-env-state="On GitHub"'), "where, and the commit it last pushed"); - assert.deepEqual(deployIn(linked, "Publish to GitHub"), { tip: null, disabled: false }, "the preview is newer: it goes"); - // Whether GitHub has this preview already is the server's to say (Publish then sends nothing): never greyed for it here. - const same = envLine(envsOf({ app: on(gh({ ingot: id("e5"), sha: "b".repeat(40), when: min(5) })) }), "prod"); - assert.deepEqual(deployIn(same, "Publish to GitHub"), { tip: null, disabled: false }); + const unlinkedApp=on(gh()); + const unlinked=await envsOf({app:unlinkedApp}); assert.ok(unlinked.includes("not linked yet")); + assert.equal(E.whyNotEnv({enabled:true},unlinkedApp,"prod",[unlinkedApp]),"Link it to GitHub first: open Technical details, then the menu on the Published line."); + // (Publish isn't beside that menu any more: no words send anyone to look for it there.) + for(const f of ["components/app/app-envs.tsx","lib/server/github-mirror.ts"]) assert.ok(!readFileSync(`${root}/${f}`,"utf8").includes("menu beside Publish"),f); + const linkedApp=on(gh({ingot:id("c1"),sha:"a".repeat(40),when:min(5)})); + const linked=await envsOf({app:linkedApp}); assert.ok(linked.includes("at aaaaaaa")); + assert.equal(E.whyNotEnv({enabled:true},linkedApp,"prod",[linkedApp]),undefined); + assert.ok(!linked.includes("Connect a domain")); + const current=await envsOf({app:on(gh({ingot:id("e5"),sha:"b".repeat(40),when:min(5)}))}); + assert.ok(parseHTML(`${current}`).document.querySelector("[data-review-publish]").hasAttribute("disabled")); // The form as the route takes it. assert.deepEqual(E.ghMirrorOf({ repo: " OrgoAI/orgo-brandlord ", branch: "", path: "formula", keep: "proxy.ts, notes/ ,", workflow: "deploy-formula.yml", inputs: "client=cclc, bad" }), { repo: "OrgoAI/orgo-brandlord", @@ -3405,6 +3156,83 @@ await test("production on GitHub (AppHosting.github): Published says where on Gi assert.deepEqual(E.ghMirrorOf({ repo: "a/b", branch: "main", path: "", keep: "", workflow: "", inputs: "" }), { repo: "a/b", branch: "main", path: "" }); }); +await test("version history opens on demand, retains full metadata, and restores only to preview", async () => { + const m=await mount(h(H.HostedAppView,{ws:"ws_main",answer:{enabled:true},app:shopApp(),history:HISTORY,onChanged:noop})); + try { + await click(button(m.box,"Back to app")); + assert.equal(m.box.querySelectorAll("[data-version]").length,0); + await click(button(m.box,"Version history")); + assert.equal(m.box.querySelectorAll("[data-version]").length,COMMITS.length); + const first=m.box.querySelector(`[data-version="${COMMITS[0].id}"]`); + assert.ok(first.textContent.includes("Adds the cart")); + assert.ok(first.querySelector('[title]').title.includes(COMMITS[0].message)); + assert.ok(first.querySelector('[title]').title.includes(COMMITS[0].id)); + assert.ok(first.querySelector('[title]').title.includes("2 added, 1 changed, 0 deleted")); + assert.equal(m.box.querySelector(`[data-version="${id("c3")}"] [data-version-tag]`).textContent,"Published"); + routes={"/api/hosted-apps/shop":{body:{app:shopApp()}}}; const n=calls.length; + await click(button(first,`Restore ${COMMITS[0].message} to preview`)); + assert.deepEqual(calls.slice(n).filter(c=>c.method!=="GET").map(c=>c.body),[{ws:"ws_main",action:"deploy",env:"preview",at:COMMITS[0].id}]); + } finally { await m.done(); } +}); +await test("offline history stays behind its disclosure with an honest unavailable state", async () => { + const m=await mount(h(H.HostedAppView,{ws:"ws_main",answer:{enabled:true},app:shopApp(),history:OFFLINE,onChanged:noop})); + try { await click(button(m.box,"Back to app")); await click(button(m.box,"Version history")); assert.match(m.box.querySelector('[data-offline]').textContent,/unavailable.*offline/); assert.equal(m.box.querySelectorAll('[data-version]').length,0); } finally { await m.done(); } +}); + +await test("S1 View changes opens one visible version history immediately", async () => { + const m=await mount(h(H.HostedAppView,{ws:"ws_history_intent",answer:{enabled:true},app:shopApp(),history:HISTORY,onChanged:noop,atHistory:true})); + try { assert.equal(m.box.querySelectorAll('[data-version]').length,COMMITS.length); assert.equal(button(m.box,"Version history").getAttribute('aria-expanded'),'true'); assert.ok(!button(m.box,"Versions")); } finally { await m.done(); } +}); +await test("S2 every restore respects unavailable hosting and explains why", async () => { + routes={"/api/hosted-apps/shop":{body:{app:shopApp()}}}; + const m=await mount(h(H.HostedAppView,{ws:"ws_restore_closed",answer:{enabled:false,why:"Sign in to Orgo to deploy."},app:shopApp(),history:HISTORY,onChanged:noop})); + try { await click(button(m.box,"Back to app")); await click(button(m.box,"Version history")); const n=calls.length; for(const row of m.box.querySelectorAll('[data-version]')) { const b=row.querySelector('button'); assert.equal(b.disabled,true); assert.match(b.parentElement.getAttribute('data-tip')??b.title,/Sign in to Orgo/); await click(b); } assert.equal(posts(n,"/api/hosted-apps/shop").length,0); } finally { await m.done(); } +}); +await test("S3 unknown change counts never become an invented number on home", () => { + const html=list({enabled:true,apps:[shopApp({preview:STAGING()})]}); assert.ok(!text(html).includes('1 unpublished')); assert.ok(text(html).includes('Unpublished changes')); +}); +await test("S4 a legacy publish names its real address or explicitly says no domain", async () => { + for(const url of [null,"https://legacy.example"]) { + const app=shopApp({preview:STAGING(),domain:null,domains:[],hosting:{...hosting("shop"),url}}); + const m=await mount(h(E.AppEnvs,{ws:"ws_legacy",answer:{enabled:true},app,sites:[app],local:null,onLocal:noop,onChanged:noop})); + try { await click(button(m.box,"Publish changes")); const t=m.box.querySelector('[data-publish-confirmation]').textContent; assert.ok(t.includes(url??'No domain yet')); assert.ok(!t.includes('Your connected domains')); assert.ok(m.box.querySelector('[data-technical-details]').textContent.includes(url??'No domain yet')); } finally { await m.done(); } + } +}); +await test("S5 the owner's rows keep member activity visible, including failures", () => { + for(const ok of [true,false]) { const act={key:'k',userId:'u',name:'Kimmy',act:'deploy',ok,said:'Build refused',at:Date.now()}; const html=list({enabled:true,apps:[shopApp()]},{actOf:()=>act}); const d=parseHTML(`${html}`).document; const note=d.querySelector('[data-member-act]'); assert.ok(note && note.textContent.includes('Kimmy')); if(!ok) assert.ok(note.className.includes('text-[#B42318]') && note.textContent.includes('Build refused')); } +}); +await test("S6 a member can see when the owner's snapshot was read", async () => { + const {SharedAppsPage}=await import(pathToFileURL(`${root}/components/app/shared-apps.tsx`).href); const html=renderToStaticMarkup(h(SharedAppsPage,{state:sharedReviewState()})); assert.match(text(html),/As of/); +}); +await test("S7 no tools gives a useful empty state and no empty list box", () => { + const state={bots:[],chats:[],workspaces:[],madeApps:[],account:{user:{id:'outside',email:'kimi@example.com'}}}; const html=renderToStaticMarkup(h(MadeAppsHome,{state,ws:'ws_main'})); assert.match(text(html),/Ask a bot/); assert.ok(!html.includes('overflow-hidden rounded-2xl')); + const web=list({enabled:true,apps:[]},{tools:[]}); assert.ok(!web.includes('overflow-hidden rounded-2xl')); +}); +const { AppReview } = await import(pathToFileURL(`${root}/components/app/app-review.tsx`).href); +const pendingReview = { sentence: "The preview has 1 change that isn't live.", tone: "waits", action: "publish" }; +const reviewProps = { status: pendingReview, previewUrl: "https://preview.example", liveUrl: "https://live.example", previewAt: id("a1"), publishedAt: id("b2"), destination: "example/repo · main", onPublish: async () => true, technical: h("span", null, "Local") }; +await test("approved review emphasis keeps the pending count and directs people to the same preview", () => { + const box=parseHTML(`${renderToStaticMarkup(h(AppReview,reviewProps))}`).document; + const state=box.querySelector('[data-review-state]'); + assert.equal(state.textContent,'1 unpublished change'); assert.ok(state.className.includes('bg-highlighter')); + const preview=box.querySelector('a'); assert.equal(preview.textContent,'Review preview'); assert.equal(preview.href,'https://preview.example'); assert.equal(preview.target,'_blank'); assert.ok(preview.className.includes('bg-highlighter')); +}); +await test("quiet, working and failed states never borrow the pending-review color", () => { + for(const status of [{sentence:'Live and up to date.',tone:'quiet'},{sentence:'Updating the preview…',tone:'working'},{sentence:'The last publish failed.',tone:'failed',action:'publish'}]) { + const box=parseHTML(`${renderToStaticMarkup(h(AppReview,{...reviewProps,status}))}`).document; + assert.ok(!box.querySelector('[data-review-state]').className.includes('bg-highlighter')); assert.equal(box.querySelector('a').textContent,'Open preview'); + } +}); +await test("the final publishing decision quiets review without creating an editing gate", async () => { + const m=await mount(h(AppReview,reviewProps)); + try { + await click(button(m.box,'Publish changes')); + assert.ok(m.box.querySelector('[data-publish-confirmation]')); + assert.equal(m.box.querySelector('a').textContent,'Review preview'); assert.ok(!m.box.querySelector('a').className.includes('bg-highlighter')); + await click(button(m.box,'Keep reviewing')); assert.ok(m.box.querySelector('a').className.includes('bg-highlighter')); + await click(button(m.box,'About preview and live')); assert.match(m.box.querySelector('[data-view-help]').textContent,/keep editing if you’re improving this one/); + } finally { await m.done(); } +}); await test("apps that use a key from Keys (app-key-links.tsx): an app's keys from Keys by name, its own with Remove and every app's, the picker for who may; the Keys row's Every app switch for This workspace's own keys; a line for an older Orgo; plain words", async () => { const K = await import(pathToFileURL(`${root}/components/app/app-key-links.tsx`).href); const can = { every: true, app: true }; diff --git a/scripts/test-hosted-apps.mjs b/scripts/test-hosted-apps.mjs index 82ba7f483..d5f2fb8d2 100644 --- a/scripts/test-hosted-apps.mjs +++ b/scripts/test-hosted-apps.mjs @@ -2084,7 +2084,7 @@ await check("deploy_app in the chat: to its Preview at its fixed link unless Ale const now = H.hostedAppsLine("boppy"); assert.ok(now.startsWith("Your workspace's apps on the web, each an Ingot repo, its source: "), now); // The three environments, the command for each, and the default flow for a change, with nothing to improvise. - assert.match(now, /Local, the live view while you work .*local_app starts, stops or reads it\. Preview, its fixed link to test and share \(Deploy in Your Apps\): deploy_app puts a commit there\./); + assert.match(now, /Local, the live view while you work .*local_app starts, stops or reads it\. Preview, its fixed link to test and share \(Update preview in Your Apps\): deploy_app puts a commit there\./); assert.match(now, /Published, its production \(Publish in Your Apps\): deploy_app with where prod puts what Preview serves there/); assert.match(now, /When Alex asks for any change to an app, call work_on_app at once, with no question first: it makes the change in its working folder, commits it to Ingot and deploys that commit to its Preview/); assert.match(now, /never through GitHub \(its API, a branch, a pull request, change_files\) or by calling Orgo yourself/); @@ -2194,7 +2194,7 @@ await check("Link and Bring in asked from a shared workspace (githubJob): on a b try { const before = structuredClone(hostedApps()["recipe-box"]); // Bring in before it's linked: said, nothing read. - assert.deepEqual(await job("githubPull"), { ok: false, said: "recipe-box isn't linked to GitHub yet. Link it first, in the menu beside Publish." }); + assert.deepEqual(await job("githubPull"), { ok: false, said: "recipe-box isn't linked to GitHub yet. Link it first, from Technical details in Your Apps." }); assert.deepEqual(web.github.calls, []); const linked = hear(await job()); assert.deepEqual(linked, { ok: true, said: "Linked recipe-box to GitHub acme/site main, folder app at 1111111. GitHub has no app/ folder yet, so the next Publish puts all of it there." }); diff --git a/scripts/test-preview-password-ui.mjs b/scripts/test-preview-password-ui.mjs index f3f21d89a..f53bdfcc6 100644 --- a/scripts/test-preview-password-ui.mjs +++ b/scripts/test-preview-password-ui.mjs @@ -41,6 +41,11 @@ registerHooks({ return { format: "module", source: out.outputText, shortCircuit: true }; }, }); + +// Initialize React before the client graph: CJS icon imports can otherwise see a partial React module under Node hooks. +await import("react"); +await import("react/jsx-runtime"); + const emitWarning = process.emitWarning.bind(process); process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); @@ -333,40 +338,24 @@ await test("the tab bar's lock shows the password with Copy; Open in browser say const classes = (el) => (el?.getAttribute("class") ?? "").split(/\s+/).filter(Boolean); const placed = (el) => [el, ...el.querySelectorAll("[class]")].filter((x) => classes(x).some((c) => /^(?:@[\w-]+:)?(?:absolute|fixed)$/.test(c))); -await test("on the Preview line in Your Apps: Password, its 7 character value in mono and Copy, one piece where the line says where it is, never cut short; what the line serves a piece of its own after it; nothing placed over anything; while it's read, what's said cut short whole in its tooltip", async () => { +await test("Your Apps reveals the current preview password only on request, with Copy and Escape", async () => { fresh(); - const E = await import(pathToFileURL(`${root}/components/app/app-envs.tsx`).href); - const AT = "5e1f0c2".repeat(9).slice(0, 40); - // (Its Preview as the list says it now: a newer password than the one kept, after fresh(), is read again.) - const envs = () => { - const app = { name: "shop", head: AT, updatedAt: base, hosting: { botId: "nova", computerId: "c1" }, serving: null, live: null, preview: preview({ serving: AT, live: { id: "d_1", at: AT, state: "succeeded", when: base, error: null, url: URL1 } }) }; - return h(E.AppEnvs, { compact: true, ws: "ws_main", answer: { enabled: true }, app, sites: [app], local: null, onLocal: () => {}, onChanged: () => {} }); - }; - const m = await mount(envs()); - const line = m.box.querySelector('[data-app-env="preview"]'); - const where = line.querySelector("[data-app-env-where]"); - const beside = line.querySelector("[data-password-beside]"); - const at = line.querySelector("[data-app-env-at]"); - assert.ok(where.contains(beside) && where.querySelector(`a[href="${URL1}"]`), "beside its link, where the line says where it is"); - assert.equal(words(beside), "Password Ab3xY9k"); - const value = beside.querySelector("[data-preview-password]"); - assert.equal(value.textContent.length, 7); - assert.ok(classes(value).includes("font-mono") && classes(value).includes("whitespace-nowrap") && !classes(value).includes("truncate"), "in mono, never cut short"); - assert.ok(classes(beside).includes("shrink-0") && classes(beside).includes("max-w-full") && !classes(beside).some((c) => /^m[lr]-/.test(c)), "one piece: the line wraps it under the link when they don't fit side by side"); - assert.ok(button(beside, "Copy password")); - assert.ok(at.parentNode === line && !where.contains(at) && !beside.contains(at) && at.textContent === AT.slice(0, 7) && at.getAttribute("title") === AT, "what it serves, a piece of its own"); - assert.deepEqual(placed(line), [], "nothing placed over anything"); - await m.done(); - // While it's read: what's said instead can be cut short, all of it in its tooltip. - fresh(); - routes = {}; - const n = await mount(envs()); - const waiting = n.box.querySelector("[data-password-beside]"); - assert.equal(words(waiting), "Password Couldn't read it just now."); - assert.ok(classes(waiting).includes("min-w-0")); - const said = waiting.lastElementChild; - assert.ok(classes(said).includes("min-w-0") && classes(said).includes("truncate") && said.getAttribute("data-tip") === "Couldn't read it just now."); - await n.done(); + const E=await import(pathToFileURL(`${root}/components/app/app-envs.tsx`).href); + const at="5e1f0c2".repeat(9).slice(0,40); + const app={name:"shop",head:at,updatedAt:base,hosting:{botId:"nova",computerId:"c1"},serving:null,live:null,preview:preview({serving:at,live:{id:"d_1",at,state:"succeeded",when:base,error:null,url:URL1}})}; + const props={ws:"ws_main",answer:{enabled:true},app,sites:[app],local:null,onLocal:()=>{},onChanged:()=>{}}; + const m=await mount(h(E.AppEnvs,props)); + try { + assert.ok(!m.box.querySelector('[data-preview-password]')); + await click(button(m.box,"Show password")); + const pop=m.box.querySelector('[data-password-popover]'); + assert.equal(pop.querySelector('[data-preview-password]').textContent,"Ab3xY9k"); + assert.ok(button(pop,"Copy password")); + assert.equal(pop.getAttribute('role'),'dialog'); + await act(async () => { const e=new win.Event('keydown',{bubbles:true,cancelable:true}); Object.defineProperty(e,'key',{value:'Escape'}); document.dispatchEvent(e); }); assert.ok(!m.box.querySelector('[data-password-popover]')); + } finally { await m.done(); } + fresh(); routes={}; const n=await mount(h(E.AppEnvs,{...props,app:{...app,preview:{...app.preview,passwordAt:base}}})); + try { await click(button(n.box,"Show password")); assert.match(n.box.querySelector('[data-password-popover]').textContent,/Couldn't read it just now/); } finally { await n.done(); } }); await test("its tab's Password section wraps New password and the switch, together, under its value in a narrow panel, never squeezing it; the tab bar's tools and widths wrap together under which one it shows", async () => { @@ -395,7 +384,8 @@ await test("where preview-tab.tsx, app-envs.tsx and hosted-apps.tsx put it", asy const envs = readFileSync(`${root}/components/app/app-envs.tsx`, "utf8"); assert.match(envs, /\n\s*\{\/\*[^*]*\*\/\}\n\s*/); const apps = readFileSync(`${root}/components/app/hosted-apps.tsx`, "utf8"); - assert.match(apps, /\n\s*/); + assert.match(apps, //); + assert.match(envs, /usePreviewPassword\(ws, app.name, site, app.preview\)/); }); console.log(`preview password ui: ${passed} passed, ${failed} failed`); diff --git a/scripts/test-shared.mjs b/scripts/test-shared.mjs index 7907decb6..60cd513cf 100644 --- a/scripts/test-shared.mjs +++ b/scripts/test-shared.mjs @@ -46,6 +46,11 @@ registerHooks({ }, }); +// Initialize React before the client graph: CJS icon imports can otherwise see a partial React module under Node hooks. +await import("react"); +await import("react/jsx-runtime"); + + // A window, enough of one (nothing here runs effects: it's rendered to HTML). globalThis.window = globalThis; globalThis.location = { origin: "http://localhost:3210" }; diff --git a/scripts/test-your-apps-e2e.mjs b/scripts/test-your-apps-e2e.mjs new file mode 100644 index 000000000..18d34c07b --- /dev/null +++ b/scripts/test-your-apps-e2e.mjs @@ -0,0 +1,407 @@ +// Your Apps, driven in the real window on the local two-person stack (scripts/dev-two-person.mjs, started here when it +// isn't up): the owner's page and an app's tab, and sam's (an Admin's) page of the same apps. The stack has no apps on +// the web (nothing real is in it), so the apps come from scripts/fixtures/your-apps.mjs, answered at the browser's edge: +// the owner's /api/hosted-apps and /api/made-apps routes, and in both windows the state feed (/api/state) with the +// owner's sign-in as Orgo's team (apps are open to them: lib/apps-access.ts), the tools the bots made, and for sam the +// workspace's apps as its owner's Mac would have written them for members. Every press that would reach Orgo (a deploy, +// Publish, a rollback, an Admin's act) is answered here too, and moves the sample on, so the page is seen following it. +// +// Shots of each screen go to /--[-].png at 1440 and 1180 wide (the Mac app's narrowest +// window: desktop/main.cjs minWidth); `--shots before` takes them of the page as it was, `--shots after` of the page as +// it is, and either skips the checks (`--look`). Without --look, every control of the new page is pressed, PASS or FAIL +// printed per check, and the run fails on any FAIL, page error or console error. is $YOUR_APPS_OUT, else +// .scratch/your-apps. Everything stays on 127.0.0.1. +// `--hold` instead opens the two windows (the owner's and sam's) in a Chrome you can see, each at Your Apps with the +// sample in it, and keeps them answered until that Chrome is closed: for a person to click around, not a check. +// Usage: node scripts/test-your-apps-e2e.mjs [--shots before|after] [--look] [--width 1440] [--hold] +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import * as F from "./fixtures/your-apps.mjs"; + +const root = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const OUT = process.env.YOUR_APPS_OUT || join(root, ".scratch", "your-apps"); +mkdirSync(OUT, { recursive: true }); +const args = process.argv.slice(2); +const arg = (k) => (args.includes(k) ? args[args.indexOf(k) + 1] : undefined); +const PREFIX = arg("--shots") ?? "after"; +const LOOK = args.includes("--look") || PREFIX === "before"; +const WIDTHS = arg("--width") ? [Number(arg("--width"))] : [1440, 1180]; +const HOLD = args.includes("--hold"); +const { chromium } = await import("playwright"); + +/* ---------------- The stack ---------------- */ + +const stateFile = join(root, ".scratch", "two-person", "stack.json"); +const running = () => { + try { + const s = JSON.parse(readFileSync(stateFile, "utf8")); + process.kill(s.pid, 0); + return s.ready ? s : null; + } catch { + return null; + } +}; +let started = false; +if (!running()) { + console.log("starting the two-person stack (sam as an Admin)…"); + const r = spawnSync(process.execPath, [join(root, "scripts", "dev-two-person.mjs"), "start"], { stdio: "inherit", env: { ...process.env, TWO_PERSON_MEMBER_ROLE: "admin" } }); + if (r.status !== 0) process.exit(1); + started = true; +} +const { urls } = running(); + +/* ---------------- The sample, as it moves on ---------------- */ + +/** The owner's apps as they are now: a deploy or Publish here moves one on, as Orgo would, a moment later. */ +const model = { apps: F.hostedApps().apps, acts: [] }; +const appOf = (name) => model.apps.find((a) => a.name === name); +const later = (f) => setTimeout(f, 1500); +/** Deploy to the Preview: its newest deploy runs, then serves the changeset (the newest, or `at`). */ +function deployPreview(a, at = a.head) { + a.preview = { ...a.preview, live: { id: `d_${Date.now()}`, at, state: "running", when: Date.now(), error: null, url: null } }; + later(() => { + a.preview = { ...a.preview, serving: at, live: { ...a.preview.live, state: "succeeded", when: Date.now() } }; + }); +} +/** Publish: what the Preview serves goes on Published (or, on GitHub, is pushed there). */ +function publish(a) { + const at = a.preview?.serving ?? a.head; + if (a.hosting?.github) { + a.hosting = { ...a.hosting, github: { ...a.hosting.github, last: { sha: "9f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6", ingot: at, when: Date.now() } } }; + return; + } + a.live = { id: `d_${Date.now()}`, at, state: "running", when: Date.now(), error: null, url: null }; + later(() => { + a.serving = at; + a.live = { ...a.live, state: "succeeded", when: Date.now() }; + }); +} + +const json = (route, body, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +/** Which of the sample's apps use keys from Keys (app-key-links.tsx reads it in Settings): none, nothing to change. The stack's own server has no apps open to answer it. */ +const noKeyLinks = (page) => page.route((u) => u.pathname === "/api/keys/apps", (route) => json(route, { links: [], can: { every: false, app: false } })); + +/** The owner's window: its state as Orgo's team with the sample apps in it, and every Your Apps route answered from the sample. */ +async function ownerRoutes(page) { + await noKeyLinks(page); + await page.route((u) => u.pathname === "/api/state", async (route) => { + // (A read still in flight as the window closes: let it go, don't fail the run on it.) + const r = await route.fetch().catch(() => null); + const j = r ? await r.json().catch(() => null) : null; + if (!j) return route.abort().catch(() => {}); + if (j.state) { + j.state.account = { ...j.state.account, user: { ...j.state.account?.user, email: F.TEAM_EMAIL } }; + j.state.madeApps = F.MADE; + j.state.workspaces = (j.state.workspaces ?? []).map((w) => (w.id === F.PROJECT.wsId ? { ...w, hostedApps: F.hostingInState(), appsForMembers: { at: Date.now(), apps: Object.fromEntries(Object.entries(F.ACTS).map(([n, acts]) => [n, { head: null, sites: [], commits: [], deploys: [], acts }])) } } : w)); + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(j) }).catch(() => {}); + }); + await page.route((u) => u.pathname.startsWith("/api/made-apps/"), async (route) => { + const u = new URL(route.request().url()); + const [, , , appId, part] = u.pathname.split("/"); + if (part === "view") return route.fulfill({ status: 200, contentType: "text/html", body: F.madeAppPage(decodeURIComponent(appId)) }); + if (!part && route.request().method() === "GET") return json(route, { code: "export default function App() { return

Trip budget

; }", draft: null }); + return json(route, { ok: true }); + }); + await page.route((u) => u.pathname.startsWith("/api/hosted-apps"), async (route) => { + const req = route.request(); + const u = new URL(req.url()); + const parts = u.pathname.split("/").slice(3); + const name = parts[0] ? decodeURIComponent(parts[0]) : undefined; + const part = parts[1]; + const env = u.searchParams.get("env") ?? undefined; + const body = req.method() === "GET" ? {} : (req.postDataJSON() ?? {}); + if (!name) return json(route, { enabled: true, apps: model.apps }); + const a = appOf(name); + if (!a) return json(route, { error: "There's no app by that name in this workspace." }, 404); + if (!part) { + if (req.method() === "GET") return json(route, { later: [] }); + model.acts.push(body); + if (body.action === "deploy" && body.env !== "prod") deployPreview(a, body.at); + else if (body.action === "deploy" || body.action === "promote") publish(a); + else if (body.action === "rollback") { + if (body.env === "preview") a.preview = { ...a.preview, serving: F.FC.prodAt, live: { ...a.preview.live, id: `d_${Date.now()}`, at: F.FC.prodAt, state: "succeeded", when: Date.now() } }; + else a.serving = F.id("c3"); + } + return json(route, { app: a }); + } + if (part === "commits") return json(route, { commits: F.COMMITS[name].map((c) => ({ ...c, serving: c.id === a.serving })), head: a.head, next: null }); + if (part === "settings") { if (req.method() !== "GET") model.acts.push(body); return json(route, req.method() === "GET" ? F.settings(name, env) : { ...F.settings(name, body.env), said: "Saved." }); } + if (part === "dns") return json(route, F.dns(name)); + if (part === "local") { + if (req.method() === "GET") return json(route, F.local(name)); + model.acts.push(body); + const l = F.local(name); + if (body.action === "start") l.sites[name] = { ...l.sites[name], state: "running", since: Date.now() }; + if (body.action === "preview") return json(route, { preview: "http://localhost:3000/" }); + return json(route, l); + } + if (part === "password") { + if (req.method() === "GET") return json(route, F.password(name)); + model.acts.push(body); + return json(route, { ...F.password(name), password: body.action === "off" ? null : "Tz4nR8w", on: body.action !== "off", at: Date.now() }); + } + if (part === "domains") return json(route, F.domainOptions()); + if (part === "logs") return json(route, F.log()); + if (part === "env") return json(route, { env: ["DATABASE_URL", "NEXTAUTH_SECRET"] }); + return json(route, { error: `not answered here: ${u.pathname}` }, 404); + }); +} + +/** Sam's window: the shared workspace's apps, as the owner's Mac would have written them, and an Admin's acts taken here. */ +async function samRoutes(page) { + await noKeyLinks(page); + await page.route((u) => u.pathname === "/api/state", async (route) => { + const r = await route.fetch().catch(() => null); + const j = r ? await r.json().catch(() => null) : null; + if (!j) return route.abort().catch(() => {}); + if (j.state?.shared) j.state.shared = { ...j.state.shared, owner: F.TEAM_EMAIL, apps: F.sharedApps(), adminPublish: true, appActs: model.samActs ?? [] }; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(j) }).catch(() => {}); + }); + await page.route((u) => u.pathname === "/api/workspaces/shared", async (route) => { + const body = route.request().method() === "POST" ? (route.request().postDataJSON() ?? {}) : {}; + if (!body.act) return route.continue(); + model.acts.push(body.act); + return json(route, { ok: true }); + }); +} + +/* ---------------- Driving ---------------- */ + +const errors = []; +let fails = 0; +let shot = 0; +const check = (name, ok) => { + console.log(`${ok ? "PASS" : "FAIL"} ${name}`); + if (!ok) fails++; +}; + +async function newPage(browser, width, who) { + // (Held for a person, the page fills whatever size they make the window.) + const context = await browser.newContext(HOLD ? { viewport: null, colorScheme: "light" } : { viewport: { width, height: 900 }, deviceScaleFactor: 2, colorScheme: "light" }); + const page = await context.newPage(); + page.on("pageerror", (e) => errors.push(`${who} pageerror: ${e.message}`)); + page.on("console", (m) => { + if (m.type() !== "error") return; + const where = m.location()?.url ?? ""; + // (Next's own dev notes aren't the page's; nor is the stand-in computer's screen, which the stack has no VNC for: the + // panel's screen tab tries it and is told 404 or 502, on the owner's page and sam's alike, apps or no apps.) + if (/Download the React DevTools|Fast Refresh/.test(m.text()) || /websockify|\/desktops\//.test(`${m.text()} ${where}`)) return; + if (/Failed to load resource/.test(m.text()) && /\/api\/(computers|screens|screen|desktops|vnc)\b|\/shared\/screen|websockify/.test(where)) return; + errors.push(`${who} console: ${m.text()}${where ? ` (${where})` : ""}`); + }); + return page; +} +const snap = async (page, screen, width) => { + await page.evaluate(() => document.fonts.ready); + shot++; + const file = `${PREFIX}-${String(shot).padStart(2, "0")}-${screen}${width === 1440 ? "" : `-${width}`}.png`; + await page.screenshot({ path: join(OUT, file), animations: "disabled" }); + console.log(`shot ${file}`); +}; + +/** + * The two windows open for a person, at Your Apps, the sample answered behind them (every press moves it on as the checks' + * do), until the Chrome is closed. Each window is its own, so a Publish pressed as the owner is seen by sam too. + */ +async function hold(browser) { + const ownerPage = await newPage(browser, WIDTHS[0], "owner"); + await ownerRoutes(ownerPage); + await ownerPage.goto(urls.owner, { waitUntil: "networkidle" }); + // (A cold dev server compiles the window on its first opening: minutes, not seconds. A window that never shows the + // button leaves a shot of itself and what the page said, so the reason is seen.) + const footer = async (page, who) => { + try { + await page.locator('[data-footer-key="apps"] button').waitFor({ timeout: 240_000 }); + } catch (e) { + await page.screenshot({ path: join(OUT, `hold-${who}-no-apps-button.png`) }).catch(() => {}); + console.log(`${who}: no Your Apps button at ${page.url()}; the page says: ${(await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 400)}`); + for (const x of errors) console.log(` ${x}`); + throw e; + } + await page.locator('[data-footer-key="apps"] button').click(); + }; + await footer(ownerPage, "owner"); + const samPage = await newPage(browser, WIDTHS[0], "sam"); + await samRoutes(samPage); + await samPage.goto(urls.sam, { waitUntil: "networkidle" }); + await samPage.locator("[data-footer-key]").first().waitFor({ timeout: 240_000 }); + // The shared workspace, opened as the switcher opens it: only there does sam have Your Apps. + const status = await samPage.evaluate((id) => fetch("/api/workspaces/shared", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ open: id }) }).then((r) => r.status), F.PROJECT.id); + if (status !== 200) throw new Error(`sam couldn't open the shared workspace: ${status}`); + await footer(samPage, "sam"); + await ownerPage.bringToFront(); + console.log(`open: the owner's Bops (${urls.owner}) and sam's, an Admin's (${urls.sam}), each at Your Apps. Close that Chrome to end.`); + await new Promise((resolve) => browser.on("disconnected", resolve)); +} + + +const same = (a,b) => JSON.stringify(a) === JSON.stringify(b); +async function refresh(page) { await page.evaluate(() => window.dispatchEvent(new Event('focus'))); await page.waitForTimeout(500); } +async function click(page, name) { await page.getByRole('button',{name,exact:true}).click(); await (page.page?.() ?? page).waitForTimeout(250); } +const attention = locator => locator.evaluate(x=>getComputedStyle(x).backgroundColor==='rgb(233, 255, 59)'); +async function layout(page, root, name) { + check(`content fits ${name}`, await root.evaluate(x=>x.scrollWidth<=x.clientWidth)); + const heights=await root.locator('[data-hosted-app], [data-made-app]').evaluateAll(xs=>xs.map(x=>x.getBoundingClientRect().height)); + if(heights.length) check(`every home row has the same height ${name}`, heights.length===5 && Math.max(...heights)-Math.min(...heights)<1); + const tags=await root.locator('[data-version-tag]').evaluateAll(xs=>xs.filter(x=>x.checkVisibility()).map(x=>x.getBoundingClientRect().x)); + if(tags.length) check(`version tag columns align ${name}`, Math.max(...tags)-Math.min(...tags)<1); +} +async function owner(browser,width) { + const page=await newPage(browser,width,'owner'); + await ownerRoutes(page); + await page.context().grantPermissions(['clipboard-read','clipboard-write'],{origin:urls.owner}); + await page.context().route(F.FC.preview, r=>r.fulfill({contentType:'text/html',body:'

Sample preview

'})); + await page.goto(urls.owner,{waitUntil:'networkidle'}); + await page.locator('[data-footer-key="apps"] button').click({timeout:60000}); + const home=page.locator('[data-hosted-apps]'); + await home.locator('[data-hosted-app="fourcorners"]').waitFor(); + await snap(page,'owner-home',width); + if(!LOOK) { + check(`home leads to review before publishing ${width}`, await home.getByRole('button',{name:'Review changes',exact:true}).count()===1 && !/Publish changes|Local|On the web/.test(await home.innerText())); + check(`home highlights the pending review ${width}`,await attention(home.getByRole('button',{name:'Review changes',exact:true}))); + check(`home has five rows including tools ${width}`, await home.locator('[data-made-app]').count()===2); + await layout(page,home,`home ${width}`); + } + await home.locator('[data-hosted-app="fourcorners"] button').first().click(); + const tab=page.locator('[data-hosted-tab="fourcorners"]'); + await tab.locator('[data-review-content]').waitFor(); + await tab.getByText(F.COMMITS.fourcorners[0].message,{exact:true}).first().waitFor(); + await snap(page,'owner-app',width); + if(!LOOK) { + check(`pending preview has an accurate highlighted count ${width}`,await tab.locator('[data-review-state]').innerText()==='1 unpublished change' && await attention(tab.locator('[data-review-state]'))); + const review=tab.getByRole('link',{name:'Review preview',exact:true}); + check(`Review preview is the highlighted next action ${width}`,await attention(review)); + if(width===1180) { + const r=await review.boundingBox(), publish=await tab.locator('[data-review-publish]').boundingBox(); + check(`narrow Publish stays left aligned on its second line ${width}`,Math.abs(r.x-publish.x)<1 && publish.y>r.y); + } + check(`default view hides Local and version history ${width}`, await tab.getByRole('tab',{name:'Preview',exact:true}).getAttribute('aria-selected')==='true' && await tab.locator('[data-app-env]').count()===0 && await tab.locator('[data-version]').count()===0); + await click(tab,'Ask Boppy'); + check(`Ask Boppy keeps the app beside an unsent draft ${width}`,await tab.isVisible() && (await page.locator('#composer').evaluate(x=>x.value ?? x.textContent)).includes('fourcorners')); + await page.locator('#composer').fill(''); + + await click(tab,'Show password'); + check(`password reveals the current value ${width}`, await tab.locator('[data-password-popover] [data-preview-password]').innerText()===F.FC.password); + await click(tab,'Copy password'); + check(`copy contains exactly the preview password ${width}`, await page.evaluate(()=>navigator.clipboard.readText())===F.FC.password); + await snap(page,'owner-password',width); + await page.keyboard.press('Escape'); + check(`Escape closes password and restores focus ${width}`, await tab.locator('[data-password-popover]').count()===0 && await tab.getByRole('button',{name:'Show password',exact:true}).evaluate(x=>x===document.activeElement)); + const popup=page.waitForEvent('popup'); + await tab.getByRole('link',{name:'Review preview',exact:true}).click(); + const preview=await popup; await preview.waitForLoadState(); + check(`Review preview opens the reviewed address ${width}`, preview.url()===F.FC.preview+'/' && await preview.getByRole('heading',{name:'Sample preview'}).count()===1); await preview.close(); + await tab.getByRole('tab',{name:'Live',exact:true}).click(); + check(`Live links to the website and labels GitHub delivery honestly ${width}`, await tab.getByRole('link',{name:'Open website',exact:true}).getAttribute('href')===`https://${F.FC.domain}` && await tab.getByText('Sent to GitHub',{exact:true}).count()===1); + await tab.getByRole('tab',{name:'Preview',exact:true}).click(); + await tab.getByRole('button',{name:'About preview and live',exact:true}).click(); + check(`explanation works on click ${width}`, await tab.locator('[data-view-help]').isVisible()); + await tab.getByRole('button',{name:'About preview and live',exact:true}).click(); + const n=model.acts.length; + await click(tab,'Publish changes'); + const confirm=tab.locator('[data-publish-confirmation]'); + check(`confirmation names version and destination before any request ${width}`, (await confirm.innerText()).includes(F.COMMITS.fourcorners[0].message) && (await confirm.innerText()).includes('OrgoAI/orgo-brandlord') && (await confirm.innerText()).includes('may update automatically') && model.acts.length===n); + check(`confirmation quiets the previous review action ${width}`,!await attention(tab.getByRole('link',{name:'Review preview',exact:true}))); + await snap(page,'owner-publish-confirmation',width); + await click(confirm,'Keep reviewing'); + check(`cancel preserves unpublished changes ${width}`,model.acts.length===n && await tab.locator('[data-review-publish]').isEnabled()); + check(`cancel restores the review emphasis ${width}`,await attention(tab.getByRole('link',{name:'Review preview',exact:true}))); + await click(tab,'Publish changes'); + appOf('fourcorners').preview.serving=F.id('c3'); await refresh(page); + check(`a moved preview cannot publish an unseen version ${width}`,await tab.locator('[data-publish-confirmation]').getByRole('button',{name:'Publish changes',exact:true}).isDisabled() && model.acts.length===n); + await click(tab.locator('[data-publish-confirmation]'),'Keep reviewing'); + appOf('fourcorners').preview.serving=F.FC.head; await refresh(page); + + await click(tab,'Version history'); await tab.locator('[data-version]').first().waitFor(); + await layout(page,tab,`versions ${width}`); await snap(page,'owner-versions',width); + await tab.locator('[data-version]').nth(2).getByRole('button',{name:/Restore/}).click(); + check(`restore sends the original preview request ${width}`,same(model.acts.at(-1),{ws:'ws_main',action:'deploy',env:'preview',at:F.id('c3')})); + await page.waitForTimeout(1900); await refresh(page); + check(`an older preview remains publishable ${width}`,await tab.locator('[data-review-publish]').innerText()==='Publish changes' && await tab.locator('[data-review-publish]').isEnabled()); + await tab.locator('[data-version]').first().getByRole('button',{name:/Restore/}).click(); + await page.waitForTimeout(1900); await refresh(page); + await click(tab,'Publish changes'); await click(tab.locator('[data-publish-confirmation]'),'Publish changes'); + check(`publish keeps the exact preview-pinned request ${width}`,same(model.acts.at(-1),{ws:'ws_main',action:'promote',site:'fourcorners',at:F.FC.head})); + await refresh(page); + check(`published preview no longer offers new changes ${width}`,await tab.locator('[data-review-state]').innerText()==='Up to date' && await tab.locator('[data-review-publish]').isDisabled()); + check(`up-to-date preview is quiet again ${width}`,!await attention(tab.locator('[data-review-state]')) && !await attention(tab.getByRole('link',{name:'Open preview',exact:true}))); + await click(tab,'Technical details'); + check(`technical details retain Local and GitHub menu ${width}`,await tab.locator('[data-app-env="local"]').count()===1 && await tab.locator('[data-prod-menu]').isVisible()); + await tab.locator('[data-prod-menu]').click(); await tab.locator('[data-gh-pull]').click(); + check(`GitHub pull retains its request ${width}`,same(model.acts.at(-1),{ws:'ws_main',action:'githubPull'})); + await tab.locator('[data-app-env="local"] [data-act="local-start"]').click(); + check(`local start retains its request ${width}`,same(model.acts.at(-1),{ws:'ws_main',action:'start'})); + await snap(page,'owner-technical-details',width); + } + await click(tab,'Settings'); + await snap(page,'owner-settings',width); + await click(tab,'Technical settings'); + const settings=tab.locator('[data-settings]'); await settings.waitFor(); + await snap(page,'owner-technical-settings',width); + if(!LOOK) { + await settings.getByRole('textbox',{name:'Build command',exact:true}).fill('npm run build:site'); + await settings.getByRole('button',{name:'Save',exact:true}).click(); + check(`preview settings retain the selected environment ${width}`,same(model.acts.at(-1),{ws:'ws_main',env:'preview',build:'npm run build:site'})); + } + await click(tab,'Back to Your Apps'); + await home.locator('[data-hosted-app="cclc"] button').first().click(); + const cclc=page.locator('[data-hosted-tab="cclc"]'); + await cclc.locator('[data-review-content]').waitFor(); + await snap(page,'owner-unpublished',width); + await click(cclc,'Add a domain'); + check(`Add a domain opens the existing form ${width}`,await cclc.locator('[data-add-domain-panel]').count()===1 || await cclc.getByText('Add a domain',{exact:true}).count()>0); + await snap(page,'owner-domain-settings',width); + await page.context().close(); +} + +async function sam(browser,width) { + const page=await newPage(browser,width,'sam'); await samRoutes(page); + await page.goto(urls.sam,{waitUntil:'networkidle'}); + await page.locator('[data-footer-key]').first().waitFor({timeout:60000}); + const status=await page.evaluate(id=>fetch('/api/workspaces/shared',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({open:id})}).then(r=>r.status),F.PROJECT.id); + if(status!==200) throw new Error(`sam shared workspace: ${status}`); + await page.locator('[data-footer-key="apps"] button').click(); + const fc=page.locator('[data-shared-app="fourcorners"]'); await fc.waitFor(); await snap(page,'admin-home',width); + if(!LOOK) check(`Admin home opens review without publishing ${width}`,await fc.getByRole('button',{name:'Review changes',exact:true}).count()===1 && await fc.locator('[data-review-content]').count()===0); + if(!LOOK) check(`Admin home highlights pending review ${width}`,await attention(fc.getByRole('button',{name:'Review changes',exact:true}))); + await fc.getByRole('button',{name:'Open fourcorners',exact:true}).click(); + await fc.locator('[data-review-content]').waitFor(); await snap(page,'admin-open',width); + if(!LOOK) { + check(`Admin shares the highlighted preview decision ${width}`,await attention(fc.locator('[data-review-state]')) && await attention(fc.getByRole('link',{name:'Review preview',exact:true}))); + check(`Admin sees Preview and Live without Local ${width}`,await fc.getByRole('tab').count()===2 && !/Local/.test(await fc.innerText())); + await click(fc,'Show password'); check(`Admin can find preview password ${width}`,await fc.locator('[data-password-popover] [data-preview-password]').innerText()===F.FC.password); await page.keyboard.press('Escape'); + await click(fc,'Version history'); await layout(page,fc,`Admin versions ${width}`); await snap(page,'admin-versions',width); + await fc.locator('[data-version]').nth(2).getByRole('button',{name:/Restore/}).click(); + check(`Admin restore keeps its request ${width}`,same(model.acts.at(-1),{act:'deploy',app:'fourcorners',at:F.id('c3')})); + check(`Admin keeps Update preview beside Publish ${width}`,await fc.locator('[data-review-update]').isEnabled() && await fc.locator('[data-review-publish]').innerText()==='Publish changes'); + const n=model.acts.length; await click(fc,'Publish changes'); + check(`Admin publishing asks before sending ${width}`,model.acts.length===n && await fc.locator('[data-publish-confirmation]').count()===1); + const said=await fc.locator('[data-publish-confirmation]').innerText(); + check(`Admin confirmation says where Publish goes ${width}`,said.includes('Publish sends it to GitHub (OrgoAI/orgo-brandlord, main), where its own deploy puts it live.') && said.includes('OrgoAI/orgo-brandlord · main')); + check(`Admin confirmation quiets review ${width}`,!await attention(fc.getByRole('link',{name:'Review preview',exact:true}))); + await click(fc.locator('[data-publish-confirmation]'),'Publish changes'); + check(`Admin Publish keeps its original pinned request ${width}`,same(model.acts.at(-1),{act:'deploy',app:'fourcorners',env:'prod',at:F.FC.head})); + await click(fc,'Technical details'); await click(fc,'Settings'); await snap(page,'admin-settings',width); + const port=fc.getByRole('textbox',{name:'Port',exact:true}); await port.fill('3001'); await click(fc,'Save'); + check(`Admin settings keep the original site body ${width}`,same(model.acts.at(-1),{act:'settings',app:'fourcorners',port:3001})); + check(`Admin never receives Local controls ${width}`,await fc.locator('[data-app-env="local"]').count()===0); + } + await page.context().close(); +} + +const browser=await chromium.launch({channel:'chrome',headless:!HOLD,args:HOLD?[`--window-size=${WIDTHS[0]},960`]:[]}); +try { + if(HOLD) await hold(browser); + else for(const width of WIDTHS) { + model.apps=F.hostedApps().apps; model.acts=[]; + await owner(browser,width); await sam(browser,width); + } +} finally { + await browser.close(); + if(started) spawnSync(process.execPath,[join(root,'scripts','dev-two-person.mjs'),'stop'],{stdio:'inherit'}); +} +for(const error of errors) console.log(`FAIL ${error}`); +console.log(fails||errors.length?`${fails} FAIL, ${errors.length} page errors`:'all PASS'); +process.exit(fails||errors.length?1:0); diff --git a/scripts/verify b/scripts/verify index 4326f2bff..1f71e8cf9 100755 --- a/scripts/verify +++ b/scripts/verify @@ -1,28 +1,52 @@ #!/usr/bin/env node +// One entry point for each feature's checks (docs/FEATURES.md). Usage: scripts/verify [browser-speed|browser-input|your-apps [--ui]], +// or --list for every target. No target: browser-speed. import { spawnSync } from 'node:child_process'; -const feature = process.argv[2] ?? 'browser-speed'; -if (feature === '--list') { - console.log('browser-speed\nbrowser-input'); - process.exit(0); -} -if (feature !== 'browser-speed' && feature !== 'browser-input') - throw new Error('Use scripts/verify browser-speed or browser-input; see docs/FEATURES.md'); -// The browser tab's input socket: the app server's sending (fake Bops Cloud), Bops Cloud's checks (needs the test database, cloud/test/edge-fakes.ts), types and lint. -const steps = feature === 'browser-input' ? [ - [process.execPath, ['scripts/test-browser-view.mjs']], - [process.execPath, ['--test', 'cloud/test/core-browser.test.ts', 'cloud/test/edge-browser.test.ts']], +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const node = process.execPath; + +// Your Apps: what the review flow touches, linted; its UI, status, password and sharing checks. --ui adds the browser pass. +// (Bops Cloud's slice of it, SharedApp.github, is checked by cloud/test/core-members.test.ts, which needs the test database.) +const yourAppsFiles = ['components/app/app-controls.tsx', 'components/app/app-review.tsx', 'components/app/app-envs.tsx', 'components/app/app-settings.tsx', 'components/app/hosted-apps.tsx', 'components/app/made-apps.tsx', 'components/app/shared-apps.tsx', 'components/app/preview-password.tsx', 'components/app/bops-app.tsx', 'components/app/crm.tsx', 'components/app/ui.tsx', 'lib/hosted-apps.ts', 'lib/app-review.ts', 'cloud/ws-slice.ts', 'cloud/protocol.ts']; + +const TARGETS = { + 'browser-speed': () => [ + [node, ['--test', 'scripts/test-browser-probe.mjs']], + [node, ['--check', 'scripts/verify']], + ['npx', ['tsc', '--noEmit']], + ['npx', ['eslint', 'scripts/browser-speed', 'scripts/probe-browser-speed.mjs', 'scripts/probe-computer-side.mjs', 'scripts/dev-two-person.mjs', 'scripts/test-browser-probe.mjs', 'cloud/browser-probe.ts', 'cloud/browser.ts', 'lib/browser-relay.ts', 'components/app/bot-browser.tsx', 'next.config.ts']], + ], + // The browser tab's input socket: the app server's sending (fake Bops Cloud), Bops Cloud's checks (needs the test database, cloud/test/edge-fakes.ts), types and lint. + 'browser-input': () => [ + [node, ['scripts/test-browser-view.mjs']], + [node, ['--test', 'cloud/test/core-browser.test.ts', 'cloud/test/edge-browser.test.ts']], ['npx', ['tsc', '--noEmit']], ['npx', ['tsc', '--noEmit', '-p', 'cloud']], ['npx', ['eslint', 'cloud/browser.ts', 'cloud/http.ts', 'cloud/live.ts', 'cloud/protocol.ts', 'cloud/test/edge-browser.test.ts', 'lib/server/browser-view.ts', 'scripts/test-browser-view.mjs', 'components/app/bot-browser.tsx', 'components/app/browser-cursor.tsx', 'scripts/browser-speed/link.mjs', 'scripts/test-browser-probe.mjs']], -] : [ - [process.execPath, ['--test', 'scripts/test-browser-probe.mjs']], - [process.execPath, ['--check', 'scripts/verify']], - ['npx', ['tsc', '--noEmit']], - ['npx', ['eslint', 'scripts/browser-speed', 'scripts/probe-browser-speed.mjs', 'scripts/probe-computer-side.mjs', 'scripts/dev-two-person.mjs', 'scripts/test-browser-probe.mjs', 'cloud/browser-probe.ts', 'cloud/browser.ts', 'lib/browser-relay.ts', 'components/app/bot-browser.tsx', 'next.config.ts']], -]; -for (const [cmd, args] of steps) { - const r = spawnSync(cmd, args, { stdio: 'inherit' }); - if (r.status !== 0) - process.exit(r.status ?? 1); - console.log(`PASS ${cmd} ${args.join(' ')}`); + ], + 'your-apps': (ui) => [ + ['npx', ['tsc', '--noEmit', '-p', 'tsconfig.json']], + ['npx', ['tsc', '--noEmit', '-p', 'cloud']], + ['npx', ['eslint', ...yourAppsFiles]], + ...['hosted-apps-ui', 'app-status', 'preview-password-ui', 'shared'].map((name) => [node, [`scripts/test-${name}.mjs`]]), + ...(ui ? [[node, ['scripts/test-your-apps-e2e.mjs']]] : []), + ], +}; + +const feature = process.argv[2] ?? 'browser-speed'; +if (feature === '--list') { + console.log(Object.keys(TARGETS).join('\n')); + process.exit(0); +} +if (!Object.hasOwn(TARGETS, feature)) { + console.error(`Use scripts/verify ${Object.keys(TARGETS).join(', ')} (your-apps takes --ui), or --list; see docs/FEATURES.md`); + process.exit(2); +} +for (const [cmd, args] of TARGETS[feature](process.argv.includes('--ui'))) { + console.log(`\nChecking ${cmd === node ? 'node' : cmd} ${args.join(' ')}`); + const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit' }); + if (r.status !== 0) process.exit(r.status ?? 1); + console.log(`PASS ${cmd === node ? 'node' : cmd} ${args.join(' ')}`); }