diff --git a/web/src/lib/api/sites.test.ts b/web/src/lib/api/sites.test.ts index efb25dd71..8eea1aca1 100644 --- a/web/src/lib/api/sites.test.ts +++ b/web/src/lib/api/sites.test.ts @@ -9,12 +9,64 @@ vi.mock("$lib/auth/agent", () => ({ hostForServiceDid: () => null })); -const { sitesPermissions, getDeployHistory } = await import("$lib/api/sites"); +const { sitesPermissions, getDeployHistory, loadSitePanel, branchChoices } = + await import("$lib/api/sites"); const { createAppviewClient } = await import("$lib/api/appview"); type AppviewContext = import("$lib/api/appview").AppviewContext; const sites = new URL("https://sites.test"); +const agent = { sub: "did:plc:alice" } as unknown as AppviewContext["agent"]; + +interface Reply { + status?: number; + body?: unknown; +} + +const ctxFor = (reply: (url: URL) => Reply, seen: URL[]): AppviewContext => + createAppviewClient({ + apiUrl: "https://sites.test", + agent, + fetch: async (input) => { + const url = input as URL; + seen.push(url); + const { status = 200, body = {} } = reply(url); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" } + }); + } + }); + +const nsidOf = (url: URL) => url.pathname.replace("/xrpc/", ""); +const called = (seen: URL[]) => seen.map(nsidOf); + +const routes = + (table: Record) => + (url: URL): Reply => + table[nsidOf(url)] ?? { body: {} }; + +const DOMAIN_CLAIM = "org.tangled.temp.site.getDomainClaim"; +const SITE_CONFIG = "org.tangled.temp.repo.getSiteConfig"; +const DEPLOY_HISTORY = "org.tangled.temp.repo.getDeployHistory"; + +const NO_SITE = { + status: 404, + body: { error: "SiteNotFound", message: "no site configuration for this repository" } +}; + +const config = { branch: "site", dir: "/docs", isIndex: false }; + +const entry = { + status: "failure", + trigger: "push", + branch: "main", + dir: "/", + commitSha: "0c4d0e9b", + error: "knot unreachable", + createdAt: "2026-07-01T10:00:00Z" +}; + describe("sites permissions", () => { it("covers every method the repo sites panel calls, the deploy history included", () => { const permissions = sitesPermissions(sites); @@ -35,40 +87,172 @@ describe("sites permissions", () => { }); describe("getDeployHistory", () => { - const agent = { sub: "did:plc:alice" } as unknown as AppviewContext["agent"]; - - const ctx = (body: unknown, seen: URL[]): AppviewContext => - createAppviewClient({ - apiUrl: "https://sites.test", - agent, - fetch: async (input) => { - seen.push(input as URL); - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" } - }); - } - }); - - const entry = { - status: "failure", - trigger: "push", - branch: "main", - dir: "/", - commitSha: "0c4d0e9b", - error: "knot unreachable", - createdAt: "2026-07-01T10:00:00Z" - }; - it("asks sites for the repo's deploys", async () => { const seen: URL[] = []; - const history = await getDeployHistory(ctx({ deploys: [entry] }, seen), "did:plc:repo"); + const ctx = ctxFor(() => ({ body: { deploys: [entry] } }), seen); + const history = await getDeployHistory(ctx, "did:plc:repo"); expect(history.deploys).toEqual([entry]); expect(seen[0].pathname).toBe("/xrpc/org.tangled.temp.repo.getDeployHistory"); expect(seen[0].searchParams.get("repoDid")).toBe("did:plc:repo"); }); it("reads a payload without the deploys key as an empty history", async () => { - expect(await getDeployHistory(ctx({}, []), "did:plc:repo")).toEqual({ deploys: [] }); + expect( + await getDeployHistory( + ctxFor(() => ({}), []), + "did:plc:repo" + ) + ).toEqual({ + deploys: [] + }); + }); + + it("still refuses a history read for a repo with no site config", async () => { + const ctx = ctxFor(() => NO_SITE, []); + await expect(getDeployHistory(ctx, "did:plc:repo")).rejects.toMatchObject({ + status: 404, + error: "SiteNotFound" + }); + }); +}); + +describe("loadSitePanel", () => { + it("reads the domain, config and deploys of a configured site", async () => { + const seen: URL[] = []; + const ctx = ctxFor( + routes({ + [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, + [SITE_CONFIG]: { body: { config } }, + [DEPLOY_HISTORY]: { body: { deploys: [entry] } } + }), + seen + ); + + await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ + domain: "alice.sites.test", + config, + deploys: [entry] + }); + expect(called(seen).sort()).toEqual([DOMAIN_CLAIM, SITE_CONFIG, DEPLOY_HISTORY].sort()); + }); + + it("loads an unconfigured repo without asking for a history it cannot have", async () => { + const seen: URL[] = []; + const ctx = ctxFor( + routes({ + [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, + [SITE_CONFIG]: { body: {} }, + [DEPLOY_HISTORY]: NO_SITE + }), + seen + ); + + await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ + domain: "alice.sites.test", + config: null, + deploys: [] + }); + expect(called(seen)).not.toContain(DEPLOY_HISTORY); + }); + + it("loads for an account with no domain claim", async () => { + const ctx = ctxFor(routes({ [SITE_CONFIG]: { body: {} } }), []); + await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ + domain: null, + config: null, + deploys: [] + }); + }); + + it("loads a configured site whose domain claim lapsed, without asking for its history", async () => { + const seen: URL[] = []; + const ctx = ctxFor( + routes({ + [SITE_CONFIG]: { body: { config } }, + [DEPLOY_HISTORY]: NO_SITE + }), + seen + ); + + await expect(loadSitePanel(ctx, "did:plc:repo")).resolves.toEqual({ + domain: null, + config, + deploys: [] + }); + expect(called(seen)).not.toContain(DEPLOY_HISTORY); + }); + + it("leaves a failed config read on the panel", async () => { + const ctx = ctxFor( + routes({ + [SITE_CONFIG]: { + status: 500, + body: { error: "InternalServerError", message: "boom" } + } + }), + [] + ); + await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 500 }); + }); + + it("keeps the owner refusal of a configured site's history", async () => { + const ctx = ctxFor( + routes({ + [DOMAIN_CLAIM]: { body: { domain: "alice.sites.test" } }, + [SITE_CONFIG]: { body: { config } }, + [DEPLOY_HISTORY]: { + status: 403, + body: { + error: "NotOwner", + message: "Only the repository owner can read this site's deploy history." + } + } + }), + [] + ); + await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ + status: 403, + error: "NotOwner" + }); + }); + + it("leaves a failed domain read on the panel", async () => { + const ctx = ctxFor( + routes({ + [DOMAIN_CLAIM]: { + status: 502, + body: { error: "UpstreamFailed", message: "no pds" } + } + }), + [] + ); + await expect(loadSitePanel(ctx, "did:plc:repo")).rejects.toMatchObject({ status: 502 }); + }); +}); + +describe("branchChoices", () => { + it("offers every branch the repo lists", () => { + expect(branchChoices("main", ["main", "preview", "docs"])).toEqual([ + { value: "main" }, + { value: "preview" }, + { value: "docs" } + ]); + }); + + it("keeps a branch the repo no longer lists at the top, without repeating it", () => { + expect(branchChoices("docs", ["main", "docs"])).toEqual([ + { value: "main" }, + { value: "docs" } + ]); + expect(branchChoices("gh-pages", ["main", "docs"])).toEqual([ + { value: "gh-pages" }, + { value: "main" }, + { value: "docs" } + ]); + }); + + it("offers nothing when neither the config nor the knot named a branch", () => { + expect(branchChoices("", [])).toEqual([]); + expect(branchChoices("", ["main"])).toEqual([{ value: "main" }]); }); }); diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts index bcd7e11da..44fd06ec6 100644 --- a/web/src/lib/api/sites.ts +++ b/web/src/lib/api/sites.ts @@ -106,3 +106,31 @@ export const getDeployHistory = async ( const res = await authedGet(ctx, GET_DEPLOY_HISTORY, { repoDid }, init); return { deploys: res.deploys ?? [] }; }; + +export interface SitePanel { + domain: string | null; + config: SiteConfig | null; + deploys: DeployHistoryEntry[]; +} + +// sites returns 404 for a repo with no config or a lapsed domain claim, so skip history until both exist to keep the setup form reachable +export const loadSitePanel = async ( + ctx: AppviewContext, + repoDid: string, + init?: XrpcRequestInit +): Promise => { + const [domain, config] = await Promise.all([ + getDomainClaim(ctx, init), + getSiteConfig(ctx, repoDid, init) + ]); + if (!config || !domain) return { domain, config: config ?? null, deploys: [] }; + const { deploys } = await getDeployHistory(ctx, repoDid, init); + return { domain, config, deploys }; +}; + +// keep a selected branch that was deleted or renamed so the form shows what the site deploys +export const branchChoices = (selected: string, names: readonly string[]): { value: string }[] => { + const listed = names.filter((name) => name.length > 0); + const values = selected && !listed.includes(selected) ? [selected, ...listed] : listed; + return values.map((value) => ({ value })); +}; diff --git a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte index 95e395c45..4f1787054 100644 --- a/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/sites/+page.svelte @@ -5,13 +5,14 @@ import { createAppviewClient } from "$lib/api/appview"; import { ClientResponseError } from "$lib/api/client"; import { - getDomainClaim, - getSiteConfig, + branchChoices, + loadSitePanel, updateSiteConfig, disableSite, deploySite, sitesPermissions, - type SiteConfig + type SiteConfig, + type DeployHistoryEntry } from "$lib/api/sites"; import Button, { button } from "$lib/components/ui/Button.svelte"; import Input from "$lib/components/ui/Input.svelte"; @@ -30,6 +31,7 @@ import SettingsSaveBar from "$lib/components/settings/SettingsSaveBar.svelte"; import CodeChip from "$lib/components/settings/CodeChip.svelte"; import Check from "$icon/check"; + import CircleX from "$icon/circle-x"; import ExternalLink from "$icon/external-link"; import Unlink from "$icon/unlink"; import Modal from "$lib/components/ui/Modal.svelte"; @@ -59,10 +61,20 @@ return err instanceof Error ? err.message : fallback; }; + const TRIGGER_LABELS: Record = { + config_change: "Config change", + push: "Push", + manual: "Manual" + }; + const triggerLabel = (trigger: string) => TRIGGER_LABELS[trigger] ?? trigger; + const localTime = (createdAt: string) => new Date(createdAt).toLocaleString(); + const RECENT_DEPLOYS = 10; + interface SitesState { domain: string | null; config: SiteConfig | null; defaultBranch: string | null; + deploys: DeployHistoryEntry[]; } const loaded = createLoad( @@ -73,13 +85,9 @@ if (!agent || !url || !repo.repoDid) return null; const ctx = createAppviewClient({ apiUrl: url, agent }); const defaultBranch = await repo.defaultBranch; - const [domain, config] = await Promise.all([ - getDomainClaim(ctx), - getSiteConfig(ctx, repo.repoDid) - ]); + const panel = await loadSitePanel(ctx, repo.repoDid); return { - domain, - config, + ...panel, defaultBranch: typeof defaultBranch === "string" ? defaultBranch : null }; }, @@ -107,16 +115,6 @@ const pick = (patch: Partial) => (picked = { ...form, ...patch }); const reset = () => (picked = undefined); - const branches = $derived.by(() => { - const options: { value: string; label?: string }[] = []; - if (form.branch) { - options.push({ value: form.branch }); - } else if (loaded.data?.defaultBranch) { - options.push({ value: loaded.data.defaultBranch }); - } - return options; - }); - const save = createAction( async () => { const agent = auth.agent; @@ -164,6 +162,7 @@ if (!agent || !url || !repo.repoDid) return false; const ctx = createAppviewClient({ apiUrl: url, agent }); await deploySite(ctx, repo.repoDid); + await loaded.run(); return true; }, (cause) => errMessage(cause, "Failed to queue redeploy.") @@ -192,8 +191,10 @@ {/snippet} {@const repo = await data.repo} + {@const branchNames = await data.branches} {@const domain = loaded.data?.domain ?? null} {@const config = loaded.data?.config ?? null} + {@const deploys = loaded.data?.deploys ?? []} {#snippet action()} @@ -275,7 +276,7 @@