Public: anyone can read this; the crew can change it.

The studio home keeps itself current: asks and apps arrive without a reload #64

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/55-the-studio-home-keeps-itself-current-ask → main 2 commits fork

  • Merged. scout/55-the-studio-home-keeps-itself-current-ask was merged into main as 715302c.

Review

Sign in to review this change.
5 files changed +218 −4
docs/STUDIO.md +10
@@ -126,6 +126,16 @@ whether it is live, what the crew last did there, and a way in. **New app**
126126 is the same flow as ever. `/?mode=dev` is the repositories list without
127127 switching the browser.
128128
129 The home keeps itself current. *Asks you*, *your apps*, *happening now* and
130 *on you* are each a live region (`data-live`, `src/web/client.ts`): a deploy
131 serving, a pull opening, closing or merging, a run starting or ending, or an
132 assignment changing refetches the home — the very same `studioHome` render
133 that drew the first paint, never a second copy of that markup — and swaps
134 just that region in place, so a card that is answered goes and a new one
135 arrives without a reload. Events within half a second of each other cost one
136 fetch. A card mid-decision (its post to the crew still in flight) is never
137 swapped out from under the click deciding it.
138
129139 Signed out, `/` in the studio is a stranger's front door, not the dev
130140 pitch: a shelf of the public apps actually serving — name, one line of
131141 description, live state, and production's own picture, linking straight to
src/web/client.ts +4
@@ -1881,6 +1881,10 @@ export const liveRegionsJs = /* js */ `
18811881 var mine = document.getElementById(id);
18821882 var theirs = fresh.getElementById(id);
18831883 if (!mine || !theirs) return;
1884 // A card mid-decision (asksJs marks it data-busy while its post is
1885 // in flight) is never pulled out from under the click that is
1886 // deciding it; the next matching event tries the swap again.
1887 if (mine.querySelector('[data-busy]')) return;
18841888 var active = document.activeElement;
18851889 if (active && mine.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
18861890 mine.innerHTML = theirs.innerHTML;
src/web/pages/studio.ts +10 −2
@@ -1627,13 +1627,21 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
16271627 const body = html`<div class="dash">
16281628 <div class="dash-main stack">
16291629 <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*">${asks}</div>
1630 <div id="live-apps" data-live="run.finished,pull.merged,pull.opened">
1630 <div
1631 id="live-apps"
1632 data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged"
1633 >
16311634 ${apps}
16321635 </div>
16331636 </div>
16341637 <aside class="dash-rail stack">
16351638 <div id="live-now" data-live="run.*">${now}</div>
1636 ${onYouCard(ctx, user) ?? null}
1639 <div
1640 id="live-on-you"
1641 data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged"
1642 >
1643 ${onYouCard(ctx, user) ?? null}
1644 </div>
16371645 </aside>
16381646 </div>`;
16391647 return page({
test/live-pages.test.ts +43
@@ -105,4 +105,47 @@ describe("live browsing pages", () => {
105105 expect(html).toContain('data-app="1"');
106106 expect(html).toContain("new EventSource");
107107 });
108
109 test("the studio home wires up asks, apps, now and on-you as live regions, signed in; a signed-out visitor gets neither", async () => {
110 const signedOut = await (
111 await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } })
112 ).text();
113 expect(signedOut).not.toContain('id="live-asks"');
114 expect(signedOut).not.toContain('id="live-on-you"');
115
116 const user = await createUser(app.db, {
117 username: "ren",
118 email: "[email protected]",
119 password: "live-pages-pass",
120 });
121 if (!user.ok) throw new Error("user");
122 const cookie = (
123 (
124 await fetch(`${base}/signin`, {
125 method: "POST",
126 body: new URLSearchParams({
127 username: "ren",
128 password: "live-pages-pass",
129 }),
130 redirect: "manual",
131 })
132 ).headers.get("set-cookie") ?? ""
133 ).split(";")[0]!;
134
135 const html = await (
136 await fetch(`${base}/`, {
137 headers: { cookie: `${cookie}; forge_mode=studio` },
138 })
139 ).text();
140 expect(html).toContain('id="live-asks"');
141 expect(html).toContain('id="live-apps"');
142 expect(html).toContain('id="live-now"');
143 expect(html).toContain('id="live-on-you"');
144 expect(html).toContain("new EventSource('/events')");
145 });
146
147 test("a live region never swaps out from under a card that is mid-decision", async () => {
148 const { liveRegionsJs } = await import("../src/web/client.ts");
149 expect(liveRegionsJs).toContain("data-busy");
150 });
108151 });
test/studio.test.ts +151 −2
@@ -13,7 +13,7 @@ import { previewEnv, type EnvName } from "../src/apps/data.ts";
1313 import { AppsRuntime } from "../src/apps/runtime.ts";
1414 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1515 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { openIssue } from "../src/actions/issues.ts";
16 import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts";
1717 import { mergePull, openPull } from "../src/actions/pulls.ts";
1818 import {
1919 commitToBranch,
@@ -21,7 +21,7 @@ import {
2121 updateRepoSettings,
2222 } from "../src/actions/repos.ts";
2323 import { setCrewRole } from "../src/core/agents.ts";
24 import { getIssue, listIssues } from "../src/core/issues.ts";
24 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
2525 import { recordLook } from "../src/core/looks.ts";
2626 import { getPull, type Pull } from "../src/core/pulls.ts";
2727 import type { Repo } from "../src/core/repos.ts";
@@ -853,6 +853,155 @@ describe("the studio home does not wait on the decider", () => {
853853 });
854854 });
855855
856 /**
857 * The studio home keeps itself current: Asks you, Your apps and On you are
858 * each a live region — no second fragment endpoint, the same `studioHome`
859 * render as the first paint, fetched again on a matching event and swapped
860 * in place by `liveRegionsJs` (`src/web/client.ts`).
861 */
862 describe("the studio home keeps itself current", () => {
863 let dataDir: string;
864 let app: App;
865 let servers: Servers;
866 let base: string;
867 let alice: User;
868 let repo: Repo;
869 let cookie: string;
870 let pull: Pull;
871 let readme: Issue;
872
873 const get = (extraCookie = "") =>
874 fetch(`${base}/`, {
875 headers: {
876 cookie: `${cookie}${extraCookie}; forge_mode=studio`,
877 },
878 redirect: "manual",
879 });
880
881 beforeAll(async () => {
882 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-live-home-"));
883 app = createApp({ dataDir, port: 0 });
884 servers = await serve(app);
885 base = servers.url;
886 const a = await createUser(app.db, {
887 username: "alice",
888 email: "[email protected]",
889 password: "a-good-passphrase",
890 adminIfFirst: true,
891 });
892 if (!a.ok) throw new Error(a.error);
893 alice = a.value;
894 cookie = (
895 (
896 await fetch(`${base}/signin`, {
897 method: "POST",
898 body: new URLSearchParams({
899 username: "alice",
900 password: "a-good-passphrase",
901 }),
902 redirect: "manual",
903 })
904 ).headers.get("set-cookie") ?? ""
905 ).split(";")[0]!;
906 const made = await createRepo(app, alice, {
907 name: "signals",
908 visibility: "private",
909 description: "",
910 });
911 if (!made.ok) throw new Error(String(made.error));
912 repo = made.value;
913 const seeded = await commitToBranch(app, alice, repo, {
914 message: "Start",
915 files: [{ path: "README.md", content: "s\n" }],
916 });
917 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
918 const enabled = await enableApp(app, alice, repo, { slug: "signals" });
919 if (!enabled.ok) throw new Error(String(enabled.error));
920 const scout = await createAgent(app, alice, { username: "scout" });
921 if (!scout.ok) throw new Error(String(scout.error));
922 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
923 const scoutUser = getUserByName(app.db, "scout")!;
924 const branch = await commitToBranch(app, alice, repo, {
925 branch: "feature/x",
926 base: "main",
927 message: "Add x",
928 files: [{ path: "x.md", content: "x\n" }],
929 });
930 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
931 const opened = await openPull(app, scoutUser, repo, {
932 title: "Add x",
933 body: "",
934 baseBranch: "main",
935 headBranch: "feature/x",
936 });
937 if (!opened.ok) throw new Error(String(opened.error));
938 pull = opened.value;
939 const run = createRun(app.db, {
940 repoId: repo.id,
941 kind: "check",
942 issueId: pull.id,
943 env: `pull-${pull.number}`,
944 commitOid: pull.headOid,
945 reason: "pull",
946 });
947 claimRun(app.db, run.id);
948 finishRun(app.db, run.id, { state: "ok", summary: "check passed" });
949
950 const asked = openIssue(app, alice, repo, { title: "Write the README" });
951 if (!asked.ok) throw new Error(String(asked.error));
952 readme = asked.value;
953 expect(assignIssue(app, alice, repo, readme, alice).ok).toBe(true);
954 });
955
956 afterAll(async () => {
957 servers.stop();
958 app.close();
959 await rm(dataDir, { recursive: true, force: true });
960 });
961
962 test("Asks you, Your apps, Happening now and On you are each a live region, with the security floor's headers", async () => {
963 const res = await get();
964 expect(res.status).toBe(200);
965 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
966 expect(res.headers.get("referrer-policy")).toBe("same-origin");
967 const home = await res.text();
968 expect(home).toMatch(/id="live-asks"\s+data-live="/);
969 expect(home).toMatch(/id="live-apps"\s*\n?\s*data-live="/);
970 expect(home).toMatch(/id="live-now"\s+data-live="/);
971 expect(home).toMatch(/id="live-on-you"\s*\n?\s*data-live="/);
972 expect(home).toContain("new EventSource('/events')");
973 expect(home).toContain("Add x");
974 expect(home).toContain("Write the README");
975 });
976
977 test("a signed-out request gets a signed-out home: no per-viewer live regions, nothing of alice's", async () => {
978 const res = await fetch(`${base}/`, {
979 headers: { cookie: "forge_mode=studio" },
980 redirect: "manual",
981 });
982 expect(res.status).toBe(200);
983 const home = await res.text();
984 expect(home).not.toContain('id="live-asks"');
985 expect(home).not.toContain('id="live-on-you"');
986 expect(home).not.toContain("Add x");
987 expect(home).not.toContain("Write the README");
988 });
989
990 test("a card leaves Asks you and Your apps once its pull merges; On you once its issue closes", async () => {
991 const before = await (await get()).text();
992 expect(before).toContain("Add x");
993 expect(before).toContain("Write the README");
994
995 const merged = await mergePull(app, alice, repo, pull, "squash");
996 expect(merged.ok).toBe(true);
997 expect(setIssueState(app, alice, repo, readme, "closed").ok).toBe(true);
998
999 const after = await (await get()).text();
1000 expect(after).not.toContain("Add x");
1001 expect(after).not.toContain("Write the README");
1002 });
1003 });
1004
8561005 /**
8571006 * The studio head of an app that is not up: the same facts the App page
8581007 * shows — why, the deploy that says the rest, and the way to try again.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs