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

Contraction: fold landing/site into the studio home's empty state #436

Merged opened this

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

Merged

mason/forge:mason/396-contraction-fold-landing-site-into-the-s → main 2 commits fork

  • Merged. mason/396-contraction-fold-landing-site-into-the-s was merged into main as b83dc4f.

Review

Sign in to review this change.
8 files changed +285 −207
docs/STUDIO.md +11 −8
@@ -354,14 +354,17 @@ everything the viewer may see) and works like this:
354354 Run logs and the crew's transcript stream line by line over their own
355355 `?follow=1` answers; they are the streamed part of a page, not a region.
356356
357 Signed out, `/` in the studio is a stranger's front door, not the dev
358 pitch: a shelf of the public apps actually serving — name, one line of
359 description, live state, and production's own picture, linking straight to
360 the app. Sign in and Create an account stay one click away; `/?mode=dev`
361 still renders the dev landing, and switches this browser to dev, same as
362 the toggle. A forge with no public apps yet —
363 most people's first visit — falls back to that same dev landing rather
364 than showing an empty shelf.
357 Signed out, `/` is a stranger's front door in either mode — the studio's
358 door, never a git pitch (`studioDoor` in `studio/home.ts`). It is a shelf of
359 the public apps a signed-out reader can see, the live ones first — name, one
360 line of description, live state, and production's own picture, linking
361 straight to the app — read from the apps themselves (`listPublicApps`), so
362 no number of plain repositories crowds an app off it. Sign in and Create an
363 account stay one click away, with the forge's ssh command beneath them when
364 the terminal is on. A forge with nothing public up yet — most people's first
365 visit — shows the same door with an empty shelf that says so. `/?mode=dev`
366 answers a stranger with the same door; the repositories are a signed-in
367 person's dev home.
365368
366369 ## The board
367370
src/core/apps.ts +16
@@ -139,6 +139,22 @@ export function listApps(db: Database): AppRecord[] {
139139 return db.query<AppRow, []>("SELECT * FROM app ORDER BY id").all().map(toApp);
140140 }
141141
142 /**
143 * The apps whose repository is public — what any reader, signed in or not,
144 * may see — newest first, at most `limit`. One query over the apps
145 * themselves, so a forge with many plain repositories never crowds an app
146 * out of the window.
147 */
148 export function listPublicApps(db: Database, limit: number): AppRecord[] {
149 return db
150 .query<AppRow, [number]>(
151 `SELECT app.* FROM app JOIN repo ON repo.id = app.repo_id
152 WHERE repo.visibility = 'public' ORDER BY app.id DESC LIMIT ?1`,
153 )
154 .all(limit)
155 .map(toApp);
156 }
157
142158 /** The apps among these repositories, keyed by repo id — one query. */
143159 export function appsForRepos(
144160 db: Database,
src/web/pages/site.ts +9 −171
@@ -1,5 +1,5 @@
11 /**
2 * The site pages: the landing page and the signed-in dashboard, sign in and
2 * The site pages: the home's dispatch and the signed-in dashboard, sign in and
33 * sign up, creating a repository, search, and the confirmation step every
44 * destructive action passes through. Plain forms, zero JavaScript required,
55 * fast everywhere — a phone in the redwoods is the reference device.
@@ -10,8 +10,6 @@
1010
1111 import { createRepo } from "../../actions/repos.ts";
1212 import { signUp } from "../../actions/users.ts";
13 import { appsRuntime } from "../../apps/runtime.ts";
14 import { appsForRepos } from "../../core/apps.ts";
1513 import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts";
1614 import {
1715 getIssuesByRef,
@@ -19,7 +17,6 @@ import {
1917 listOpenAgentPullsVisibleTo,
2018 type Issue,
2119 } from "../../core/issues.ts";
22 import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts";
2320 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2421 import { listPins, pinnedRepoIds } from "../../core/pins.ts";
2522 import {
@@ -40,21 +37,19 @@ import {
4037 import { isThemeName } from "../../design/themes.ts";
4138 import { crossOrigin, type Ctx } from "../../http/context.ts";
4239 import { askCountFor, askSentence } from "../studio/asks.ts";
43 import { studioHome } from "../studio/home.ts";
40 import { studioDoor, studioHome } from "../studio/home.ts";
4441 import { edgeLoginUrl } from "../../http/edge.ts";
4542 import { sshCommand } from "../../ssh/command.ts";
4643 import {
4744 hostOf,
4845 notFoundPage,
4946 pageHead,
50 repoBase,
5147 signInFirst,
5248 } from "../chrome.ts";
5349 import { RateLimiter } from "../../core/ratelimit.ts";
5450 import { field, readForm } from "../form.ts";
5551 import { html, when, type Html } from "../html.ts";
5652 import { icon, type IconName } from "../icons.ts";
57 import { lookUrl, pictured } from "./looks.ts";
5853 import {
5954 eventActor,
6055 eventVerb,
@@ -66,7 +61,6 @@ import {
6661 btn,
6762 card,
6863 empty,
69 envStateBadge,
7064 field as formField,
7165 iconBtn,
7266 listCard,
@@ -485,158 +479,6 @@ function orgsCard(ctx: Ctx, user: User): Html {
485479 );
486480 }
487481
488 /**
489 * The studio's front door for a stranger: the public apps actually
490 * serving, as a shelf of pictures, name and one clamped line — not the dev
491 * pitch, which a signed-out reader reaches through `?mode=dev` (unchanged,
492 * `landing` below) exactly as a signed-in one reaches it through the mode
493 * switch. A forge with nothing public up yet — most people's first visit —
494 * falls back to that same dev landing rather than showing an empty shelf.
495 *
496 * Two grouped reads carry the whole shelf (the apps, then their looks), and
497 * a repository's runtime state is a map lookup (`AppsRuntime.status`), not
498 * a process call — so this never gets slower as the forge grows apps, the
499 * one page anonymous traffic actually hits.
500 */
501 function studioLanding(ctx: Ctx): Html {
502 const db = ctx.app.db;
503 const publicRepos = listReposVisibleTo(db, undefined, { limit: 24 });
504 const records = appsForRepos(
505 db,
506 publicRepos.map((repo) => repo.id),
507 );
508 const appRepos = publicRepos.filter((repo) => records.has(repo.id));
509 if (appRepos.length === 0) return landing(ctx);
510
511 const runtime = appsRuntime(ctx.app);
512 const looks = looksForApps(
513 db,
514 appRepos.map((repo) => records.get(repo.id)!.id),
515 );
516 const tiles = appRepos.map((repo) => {
517 const record = records.get(repo.id)!;
518 const production = (
519 runtime === undefined ? [] : runtime.status(record)
520 ).find((env) => env.env === "production");
521 const ofApp = looks.filter(
522 (l) =>
523 l.appId === record.id &&
524 l.env === "production" &&
525 l.page === DEFAULT_PAGE,
526 );
527 const look = pictured(ofApp, "thumb") ?? pictured(ofApp, "desktop");
528 const base = repoBase(repo);
529 return html`<a class="app-tile" href="${base}">
530 ${look
531 ? html`<img
532 class="app-tile-look"
533 src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}"
534 width="${LOOK_SIZES[look.kind].width}"
535 height="${LOOK_SIZES[look.kind].height}"
536 alt=""
537 loading="lazy"
538 />`
539 : html`<span class="app-tile-blank" aria-hidden="true"
540 >${icon("rocket")}</span
541 >`}
542 <span class="app-tile-body">
543 <span class="app-tile-name">${repo.name}</span>
544 ${repo.description !== ""
545 ? html`<span class="app-tile-desc truncate"
546 >${repo.description}</span
547 >`
548 : null}
549 ${envStateBadge(production?.state ?? "stopped")}
550 </span>
551 </a>`;
552 });
553
554 return html`<section class="hero">
555 <h1>Apps, live.</h1>
556 <p>
557 A software forge that builds and serves its own apps — these are
558 running here right now.
559 </p>
560 <div class="bar">
561 ${ctx.app.config.edgeToken === undefined
562 ? html`${btn("Create an account", {
563 href: "/signup",
564 variant: "primary",
565 size: "lg",
566 })}
567 ${btn("Sign in", { href: "/signin", size: "lg" })}`
568 : btn("Sign in", {
569 href: edgeLoginUrl("/"),
570 variant: "primary",
571 size: "lg",
572 })}
573 </div>
574 </section>
575 <div class="mt app-shelf">${tiles}</div>`;
576 }
577
578 function landing(ctx: Ctx): Html {
579 const publicRepos = listReposVisibleTo(ctx.app.db, undefined, { limit: 8 });
580 return html`<section class="hero">
581 <h1>Your code, your machine.</h1>
582 <p>
583 A software forge you run yourself: real git over HTTPS, issues, pull
584 requests with review, webhooks and a JSON API — in one process, with
585 zero runtime dependencies.
586 </p>
587 <div class="bar">
588 ${ctx.app.config.edgeToken === undefined
589 ? html`${btn("Create an account", {
590 href: "/signup",
591 variant: "primary",
592 size: "lg",
593 })}
594 ${btn("Sign in", { href: "/signin", size: "lg" })}`
595 : btn("Sign in", {
596 href: edgeLoginUrl("/"),
597 variant: "primary",
598 size: "lg",
599 })}
600 </div>
601 ${when(
602 sshCommand(ctx.app),
603 (ssh) =>
604 html`<p class="hero-terminal">
605 <code class="mono">${ssh}</code>
606 <span class="muted"
607 >the whole forge in your terminal — your key is your
608 identity</span
609 >
610 </p>`,
611 )}
612 <div class="hero-points">
613 ${card(
614 html`<strong>Real git, and a real terminal</strong>Clone, fetch and
615 push over HTTPS or SSH; the same port opens the forge as a
616 keyboard-driven app. Branch protection is enforced by the
617 pre-receive hook, not by convention.`,
618 )}
619 ${card(
620 html`<strong>Issues and review</strong>Threads, labels, and pull
621 requests with word-level diffs and comments anchored to a line.`,
622 )}
623 ${card(
624 html`<strong>Built for automation</strong>Every mutation emits a
625 durable event. Webhooks are signed and retried; the API mirrors the
626 web.`,
627 )}
628 </div>
629 </section>
630 ${when(
631 publicRepos.length > 0,
632 () =>
633 html`<div class="mt">
634 <h2 class="mb">Public repositories</h2>
635 ${listCard(publicRepos.map((repo) => repoRow(repo)))}
636 </div>`,
637 )}`;
638 }
639
640482 /** The terminal is a front door, not a footnote: the command, on the dashboard. */
641483 function terminalCard(ctx: Ctx): Html {
642484 const ssh = sshCommand(ctx.app);
@@ -736,20 +578,16 @@ function dashboard(ctx: Ctx, user: User): Html {
736578
737579 export async function home(ctx: Ctx): Promise<Response> {
738580 const user = ctx.auth.user;
739 // Studio by default: apps, the crew, what asks you. `?mode=dev` on the
740 // home is the escape hatch — for a stranger as much as for someone
741 // signed in — resolved once for `ctx.mode`.
742 const studio = ctx.mode === "studio";
743 if (user !== undefined && studio) return await studioHome(ctx, user);
581 // A stranger gets the studio's door in either mode. Signed in, studio is
582 // the default: apps, the crew, what asks you; `?mode=dev` on the home is
583 // the escape hatch to the repositories, resolved once for `ctx.mode`.
584 if (user === undefined) return studioDoor(ctx);
585 if (ctx.mode === "studio") return await studioHome(ctx, user);
744586 return page({
745587 ctx,
746588 title: "forge",
747 body: user
748 ? dashboard(ctx, user)
749 : studio
750 ? studioLanding(ctx)
751 : landing(ctx),
752 ...(user === undefined ? {} : { scriptJs: liveClientJs }),
589 body: dashboard(ctx, user),
590 scriptJs: liveClientJs,
753591 });
754592 }
755593
src/web/studio/home.ts +134 −9
@@ -4,31 +4,32 @@
44 * beside it what is happening right now across all of them, and what a
55 * person put on you. Built from the same rows and cards as the rest of the
66 * forge, from grouped reads only, so the page does not get slower as the
7 * apps grow.
7 * apps grow. A stranger gets the door instead: the public apps, live first.
88 */
99
10 import { appsRuntime, type EnvState } from "../../apps/runtime.ts";
1011 import { crewForRepos } from "../../core/agents.ts";
11 import type { Repo } from "../../core/repos.ts";
12 import { listPublicApps } from "../../core/apps.ts";
13 import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts";
14 import { getReposByIds, type Repo } from "../../core/repos.ts";
1215 import { type User } from "../../core/users.ts";
1316 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
1417 import type { Ctx } from "../../http/context.ts";
18 import { edgeLoginUrl } from "../../http/edge.ts";
19 import { sshCommand } from "../../ssh/command.ts";
20 import { repoBase } from "../chrome.ts";
1521 import { liveRegionsJs } from "../client.ts";
1622 import { html, when, type Html } from "../html.ts";
1723 import { fragment, page } from "../layout.ts";
1824 import { liveClientJs } from "../live.ts";
19 import { btn, empty, icon, listCard, row } from "../ui.ts";
25 import { btn, empty, envStateBadge, icon, listCard } from "../ui.ts";
2026 import { onYouCard } from "../pages/site.ts";
27 import { lookUrl, pictured } from "../pages/looks.ts";
2128 import { writableAppsOf } from "./asks.ts";
2229 import { productionFactsForRepos, shelfRows } from "./shelf.ts";
2330 import { servingHistory } from "./words.ts";
2431 import { feedJs, feedNote, feedRegion } from "./feed.ts";
2532
26 /**
27 * The home in the studio: your apps as a shelf of
28 * rows — and beside it what is happening right now across all of them, and
29 * what a person put on you. Built from the same rows and cards as the rest
30 * of the forge, so it reads as the forge.
31 */
3233 /** The events that can change a row on the shelf. */
3334 const APPS_LIVE =
3435 "app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged";
@@ -163,3 +164,127 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
163164 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}`,
164165 });
165166 }
167
168 /** How many public apps the door reads, and how many tiles it shows. */
169 const DOOR_READ = 200;
170 const DOOR_TILES = 24;
171
172 /** Live first: what answers now, then what wakes on a visit, then the rest. */
173 const LIVENESS: Record<EnvState, number> = {
174 running: 0,
175 asleep: 1,
176 deploying: 2,
177 failed: 3,
178 stopped: 4,
179 };
180
181 /**
182 * The studio's door for a stranger: the public apps as a shelf of
183 * pictures, name and one clamped line, live first — and when nothing
184 * public is up yet, an empty shelf that says so, never a git pitch. A
185 * signed-out reader gets this whatever the mode: dev has no home of its
186 * own for someone with nothing to show them.
187 *
188 * Three grouped reads carry the whole shelf (the apps, their repositories,
189 * their looks), and a repository's runtime state is a map lookup
190 * (`AppsRuntime.status`), not a process call — so this never gets slower
191 * as the forge grows apps, the one page anonymous traffic actually hits.
192 */
193 export function studioDoor(ctx: Ctx): Response {
194 const db = ctx.app.db;
195 const records = listPublicApps(db, DOOR_READ);
196 const repos = getReposByIds(
197 db,
198 records.map((r) => r.repoId),
199 );
200 const runtime = appsRuntime(ctx.app);
201 const apps = records.flatMap((record) => {
202 const repo = repos.get(record.repoId);
203 if (repo === undefined) return [];
204 const production = (
205 runtime === undefined ? [] : runtime.status(record)
206 ).find((env) => env.env === "production");
207 return [{ record, repo, state: production?.state ?? "stopped" }];
208 });
209 // `sort` is stable: newest first within each liveness.
210 const shown = apps
211 .sort((a, b) => LIVENESS[a.state] - LIVENESS[b.state])
212 .slice(0, DOOR_TILES);
213 const looks = looksForApps(
214 db,
215 shown.map((a) => a.record.id),
216 );
217 const tiles = shown.map(({ record, repo, state }) => {
218 const ofApp = looks.filter(
219 (l) =>
220 l.appId === record.id &&
221 l.env === "production" &&
222 l.page === DEFAULT_PAGE,
223 );
224 const look = pictured(ofApp, "thumb") ?? pictured(ofApp, "desktop");
225 const base = repoBase(repo);
226 return html`<a class="app-tile" href="${base}">
227 ${look
228 ? html`<img
229 class="app-tile-look"
230 src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}"
231 width="${LOOK_SIZES[look.kind].width}"
232 height="${LOOK_SIZES[look.kind].height}"
233 alt=""
234 loading="lazy"
235 />`
236 : html`<span class="app-tile-blank" aria-hidden="true"
237 >${icon("rocket")}</span
238 >`}
239 <span class="app-tile-body">
240 <span class="app-tile-name">${repo.name}</span>
241 ${repo.description !== ""
242 ? html`<span class="app-tile-desc truncate"
243 >${repo.description}</span
244 >`
245 : null}
246 ${envStateBadge(state)}
247 </span>
248 </a>`;
249 });
250
251 const body = html`<section class="hero">
252 <h1>Apps, live.</h1>
253 <p>A software forge that builds and serves its own apps.</p>
254 <div class="bar">
255 ${ctx.app.config.edgeToken === undefined
256 ? html`${btn("Create an account", {
257 href: "/signup",
258 variant: "primary",
259 size: "lg",
260 })}
261 ${btn("Sign in", { href: "/signin", size: "lg" })}`
262 : btn("Sign in", {
263 href: edgeLoginUrl("/"),
264 variant: "primary",
265 size: "lg",
266 })}
267 </div>
268 ${when(
269 sshCommand(ctx.app),
270 (ssh) =>
271 html`<p class="hero-terminal">
272 <code class="mono">${ssh}</code>
273 <span class="muted"
274 >the whole forge in your terminal — your key is your
275 identity</span
276 >
277 </p>`,
278 )}
279 </section>
280 ${tiles.length > 0
281 ? html`<div class="mt app-shelf">${tiles}</div>`
282 : html`<div class="mt">
283 ${empty({
284 icon: "rocket",
285 title: "Nothing public is up yet",
286 note: "Apps made public here show up on this shelf once they run.",
287 })}
288 </div>`}`;
289 return page({ ctx, title: "forge", body });
290 }
src/web/style/components.ts +1 −1
@@ -236,7 +236,7 @@ a.sha:hover { color: var(--accent); }
236236 /* Cards stacked in normal flow space themselves. Inside a container that
237237 already manages its own spacing with gap, that margin would double it. */
238238 .card + .card { margin-top: 12px; }
239 :is(.stack, .hero-points, .dash, .split, .timeline) > .card + .card { margin-top: 0; }
239 :is(.stack, .dash, .split, .timeline) > .card + .card { margin-top: 0; }
240240 .card-head {
241241 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
242242 padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--muted);
src/web/style/content.ts +2 −8
@@ -340,14 +340,8 @@ form.inline { display: inline; margin-left: auto; }
340340 .hero-terminal .mono { font-size: 15px; }
341341 .terminal-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
342342 .hero .bar { justify-content: center; }
343 .hero-points {
344 display: grid; gap: 12px; margin-top: 40px; text-align: left;
345 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
346 }
347 .hero-points .card-body { font-size: 13px; color: var(--muted-fg); }
348 .hero-points strong { display: block; color: var(--fg); font-size: 14px; margin-bottom: 3px; }
349343
350 /* The studio's front door: the public apps that are serving, as pictures. */
344 /* The studio's front door: the public apps, live first, as pictures. */
351345 .app-shelf {
352346 display: grid; gap: 16px; max-width: 1040px; margin-inline: auto;
353347 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -370,7 +364,7 @@ form.inline { display: inline; margin-left: auto; }
370364 *content*, so a column with a long unbreakable line pushes the page wider
371365 than the phone rather than letting its own ellipsis do the work. A zero
372366 min-width is what makes truncation inside a grid actually truncate. */
373 .dash > *, .split > *, .repo-layout > *, .hero-points > * { min-width: 0; }
367 .dash > *, .split > *, .repo-layout > * { min-width: 0; }
374368
375369 /* The generic two-column page: content, then a sidebar that sticks. */
376370 .split { display: grid; gap: 16px; margin-top: 16px; align-items: start; }
src/web/ui.ts +1 −1
@@ -544,7 +544,7 @@ export function labelCss(labels: Iterable<LabelLike | string>): string {
544544 * a column of rows lines up.
545545 *
546546 * This is the only place a count gets drawn, which is what makes a repository
547 * row on the dashboard, a profile, the landing page, the search results and
547 * row on the dashboard, a profile, the search results and
548548 * the origin picker all say the same thing in the same shape.
549549 */
550550 export function counts(
test/studio.test.ts +111 −9
@@ -1355,13 +1355,16 @@ describe("the studio", () => {
13551355 );
13561356 expect(publicLook.status).toBe(200);
13571357
1358 // `?mode=dev` still renders the dev landing, unchanged, for a stranger.
1358 // `?mode=dev` answers a stranger with the same door: the dev landing
1359 // folded into it (#396).
13591360 const dev = await (
13601361 await fetch(`${base}/?mode=dev`, {
13611362 headers: { cookie: "forge_mode=studio" },
13621363 })
13631364 ).text();
1364 expect(dev).toContain("Your code, your machine");
1365 expect(dev).toContain("<h1>Apps, live.</h1>");
1366 expect(dev).toContain("a lighthouse in the fog");
1367 expect(dev).not.toContain("<h1>Your code, your machine.</h1>");
13651368 });
13661369
13671370 test("the front door's tile is the thumb, not the full desktop picture, when both exist (#287)", async () => {
@@ -1563,17 +1566,116 @@ describe("the studio", () => {
15631566 expect(aliceApps).toContain('href="/alice/lighthouse"');
15641567 });
15651568
1566 test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => {
1569 test("a forge with no public apps opens on the studio's door with an empty shelf, never dev's git pitch — at every old landing URL (#396)", async () => {
15671570 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-"));
15681571 const app2 = createApp({ dataDir: dataDir2, port: 0 });
15691572 const servers2 = await serve(app2);
15701573 try {
1571 const body = await (
1572 await fetch(`${servers2.url}/`, {
1573 headers: { cookie: "forge_mode=studio" },
1574 })
1575 ).text();
1576 expect(body).toContain("Your code, your machine");
1574 // A public repository that is not an app is not on the shelf.
1575 const owner = await createUser(app2.db, {
1576 username: "olga",
1577 email: "[email protected]",
1578 password: "a-good-passphrase",
1579 });
1580 if (!owner.ok) throw new Error(owner.error);
1581 const plain = await createRepo(app2, owner.value, {
1582 name: "just-code",
1583 visibility: "public",
1584 });
1585 if (!plain.ok) throw new Error(String(plain.error));
1586 // The landing's old addresses: the default, each mode's cookie, and
1587 // the `?mode=dev` escape hatch.
1588 for (const [path, cookie] of [
1589 ["/", ""],
1590 ["/", "forge_mode=studio"],
1591 ["/", "forge_mode=dev"],
1592 ["/?mode=dev", ""],
1593 ["/?mode=studio", "forge_mode=dev"],
1594 ] as const) {
1595 const response = await fetch(`${servers2.url}${path}`, {
1596 headers: { cookie },
1597 redirect: "manual",
1598 });
1599 expect(response.status).toBe(200);
1600 const body = await response.text();
1601 expect(body).toContain("<h1>Apps, live.</h1>");
1602 expect(body).toContain("Nothing public is up yet");
1603 expect(body).toContain('href="/signup"');
1604 expect(body).toContain('href="/signin"');
1605 expect(body).not.toContain("<h1>Your code, your machine.</h1>");
1606 expect(body).not.toContain("Public repositories");
1607 expect(body).not.toContain(`href="/olga/just-code"`);
1608 }
1609 } finally {
1610 servers2.stop();
1611 app2.close();
1612 await rm(dataDir2, { recursive: true, force: true });
1613 }
1614 });
1615
1616 test("the door reads public apps, not a window of the newest repositories, and puts the live ones first (#288)", async () => {
1617 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-door-"));
1618 const app2 = createApp({ dataDir: dataDir2, port: 0 });
1619 const servers2 = await serve(app2);
1620 try {
1621 const made = await createUser(app2.db, {
1622 username: "pia",
1623 email: "[email protected]",
1624 password: "a-good-passphrase",
1625 });
1626 if (!made.ok) throw new Error(made.error);
1627 const pia = made.value;
1628 const appOf = async (name: string) => {
1629 const created = await createRepo(app2, pia, {
1630 name,
1631 visibility: "public",
1632 });
1633 if (!created.ok) throw new Error(String(created.error));
1634 const enabled = await enableApp(app2, pia, created.value, {
1635 slug: name,
1636 });
1637 if (!enabled.ok) throw new Error(String(enabled.error));
1638 return getAppByRepo(app2.db, created.value.id)!;
1639 };
1640 const older = await appOf("older");
1641 await appOf("quiet");
1642 // More public plain repositories than the old window held, all newer
1643 // than the apps.
1644 for (let n = 0; n < 25; n++) {
1645 const r = await createRepo(app2, pia, {
1646 name: `plain-${n}`,
1647 visibility: "public",
1648 });
1649 if (!r.ok) throw new Error(String(r.error));
1650 }
1651 // `older` is serving, `quiet` is not: live first, though older. A
1652 // runtime registered for this forge, never started, answers with
1653 // the stubbed states.
1654 new AppsRuntime(app2, {
1655 domain: "studio-door.test",
1656 scheme: "http",
1657 publicPort: servers2.port,
1658 forgeUrl: servers2.url,
1659 });
1660 const status = spyOn(AppsRuntime.prototype, "status").mockImplementation(
1661 (record: AppRecord): EnvStatus[] =>
1662 [
1663 {
1664 env: "production",
1665 state: record.id === older.id ? "running" : "stopped",
1666 } as EnvStatus,
1667 ],
1668 );
1669 try {
1670 const body = await (await fetch(`${servers2.url}/`)).text();
1671 const at = (name: string) => body.indexOf(`href="/pia/${name}"`);
1672 expect(at("older")).toBeGreaterThan(-1);
1673 expect(at("quiet")).toBeGreaterThan(-1);
1674 expect(at("older")).toBeLessThan(at("quiet"));
1675 expect(body).not.toContain('href="/pia/plain-0"');
1676 } finally {
1677 status.mockRestore();
1678 }
15771679 } finally {
15781680 servers2.stop();
15791681 app2.close();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs