From bdd99f13f23da2ee883ceff8cb096aca87f46361 Mon Sep 17 00:00:00 2001 From: Nishiki Liu Date: Thu, 12 Nov 2026 14:26:00 -0800 Subject: [PATCH 1/4] feat(hub): add read-only prototype detail API Members can read one prototype with its Builds and a selected successful Build. Preview URLs stay empty until Hub hosts Builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 335a01a0-881c-4159-bcf9-5f19b9b66bd4 --- .../find-prototype-detail/index.ts | 127 ++++++ .../index.ts | 3 + hub/server/routes/api/product/types.ts | 6 + .../prototypes/show/get/index.db.test.ts | 384 ++++++++++++++++++ .../repositories/prototypes/show/get/index.ts | 75 ++++ hub/server/routes/index.ts | 5 + 6 files changed, 600 insertions(+) create mode 100644 hub/server/repositories/prototype-index/find-prototype-detail/index.ts create mode 100644 hub/server/routes/api/repositories/prototypes/show/get/index.db.test.ts create mode 100644 hub/server/routes/api/repositories/prototypes/show/get/index.ts diff --git a/hub/server/repositories/prototype-index/find-prototype-detail/index.ts b/hub/server/repositories/prototype-index/find-prototype-detail/index.ts new file mode 100644 index 00000000..0dfc6104 --- /dev/null +++ b/hub/server/repositories/prototype-index/find-prototype-detail/index.ts @@ -0,0 +1,127 @@ +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +const hiddenBranches = ["replica/install-workflow"] + +export async function findPrototypeDetail( + database: Pick, + repositoryId: string, + prototypeId: string, + buildNumber?: number, +) { + const detail = await database.$transaction( + async (transaction) => { + const repository = await transaction.repository.findFirst({ + where: { id: repositoryId, removedAt: null }, + select: { id: true, nameWithOwner: true, defaultBranch: true }, + }) + if (!repository) return null + + const prototype = await transaction.prototype.findFirst({ + where: { + id: prototypeId, + repositoryId, + replica: false, + branchName: { notIn: [repository.defaultBranch, ...hiddenBranches] }, + }, + select: { + id: true, + repositoryId: true, + name: true, + description: true, + branchName: true, + author: { select: { profile: { select: { login: true, name: true, provider: true } } } }, + currentBuildId: true, + }, + }) + if (!prototype) return null + + const builds = await transaction.build.findMany({ + where: { prototypeId }, + select: { + id: true, + number: true, + state: true, + commitSha: true, + authorProvider: true, + authorIssuer: true, + authorProviderUserId: true, + authorLogin: true, + authorName: true, + authorAvatarUrl: true, + createdAt: true, + workflowCreatedAt: true, + startedAt: true, + finishedAt: true, + failureSummary: true, + }, + }) + builds.sort((first, second) => { + const firstCreatedAt = (first.workflowCreatedAt ?? first.createdAt).getTime() + const secondCreatedAt = (second.workflowCreatedAt ?? second.createdAt).getTime() + return secondCreatedAt - firstCreatedAt || second.number - first.number + }) + + const latestBuild = + builds.find(({ id }) => id === prototype.currentBuildId) ?? builds[0] ?? null + const successfulBuilds = builds.filter(({ state }) => state === "successful") + const selectedBuild = buildNumber + ? (successfulBuilds.find(({ number }) => number === buildNumber) ?? null) + : (successfulBuilds[0] ?? null) + const buildRecords = builds.map((build) => ({ + number: build.number, + state: build.state, + commitSha: build.commitSha, + commitAuthorProvider: build.authorProvider, + commitAuthorIssuer: build.authorIssuer, + commitAuthorProviderUserId: build.authorProviderUserId, + commitAuthorLogin: build.authorLogin, + commitAuthorName: build.authorName, + commitAuthorAvatarUrl: build.authorAvatarUrl, + createdAt: build.createdAt, + startedAt: build.startedAt, + finishedAt: build.finishedAt, + failureSummary: build.failureSummary, + repositoryId: prototype.repositoryId, + prototypeId: prototype.id, + previewUrl: null, + desktopScreenshotUrl: null, + narrowScreenshotUrl: null, + })) + const selectedBuildRecord = selectedBuild + ? (buildRecords.find(({ number }) => number === selectedBuild.number) ?? null) + : null + + return { + repository, + prototype: { + id: prototype.id, + repositoryId: prototype.repositoryId, + name: prototype.name?.trim() || prototype.branchName, + branchName: prototype.branchName, + description: prototype.description, + authorLogin: prototype.author?.profile?.login ?? null, + authorName: prototype.author?.profile?.name ?? null, + authorProvider: prototype.author?.profile?.provider ?? null, + canEdit: false, + latestBuild: latestBuild + ? { number: latestBuild.number, state: latestBuild.state } + : null, + builds: buildRecords, + }, + selectedBuild: selectedBuildRecord, + } + }, + { isolationLevel: "RepeatableRead" }, + ) + if (!detail) return null + + const currentRepository = await database.repository.findFirst({ + where: { + id: repositoryId, + removedAt: null, + defaultBranch: detail.repository.defaultBranch, + }, + select: { id: true }, + }) + return currentRepository ? detail : null +} diff --git a/hub/server/routes/api/product/create-product-route-dependencies/index.ts b/hub/server/routes/api/product/create-product-route-dependencies/index.ts index 4e3e06e2..8b8651ff 100644 --- a/hub/server/routes/api/product/create-product-route-dependencies/index.ts +++ b/hub/server/routes/api/product/create-product-route-dependencies/index.ts @@ -1,6 +1,7 @@ import { prisma } from "../../../../integrations/prisma/index.ts" import { findRepositoryAuthors } from "../../../../repositories/prototype-index/find-repository-authors/index.ts" import { findRepositoryIndex } from "../../../../repositories/prototype-index/find-repository-index/index.ts" +import { findPrototypeDetail } from "../../../../repositories/prototype-index/find-prototype-detail/index.ts" import { listRepositories } from "../../../../repositories/prototype-index/list-repositories/index.ts" import type { ProductRouteDependencies } from "../types.ts" @@ -9,6 +10,8 @@ export function createProductRouteDependencies(): ProductRouteDependencies { listRepositories: () => listRepositories(prisma), findRepositoryIndex: (repositoryId, filters) => findRepositoryIndex(prisma, repositoryId, filters), + findPrototypeDetail: (repositoryId, prototypeId, buildNumber) => + findPrototypeDetail(prisma, repositoryId, prototypeId, buildNumber), findRepositoryAuthors: (repositoryId, filters) => findRepositoryAuthors(prisma, repositoryId, filters), } diff --git a/hub/server/routes/api/product/types.ts b/hub/server/routes/api/product/types.ts index 356251f5..5c42a760 100644 --- a/hub/server/routes/api/product/types.ts +++ b/hub/server/routes/api/product/types.ts @@ -6,6 +6,7 @@ import type { findRepositoryIndex, RepositoryIndexFilters, } from "../../../repositories/prototype-index/find-repository-index/index.ts" +import type { findPrototypeDetail } from "../../../repositories/prototype-index/find-prototype-detail/index.ts" import type { listRepositories } from "../../../repositories/prototype-index/list-repositories/index.ts" export interface ProductRouteDependencies { @@ -14,6 +15,11 @@ export interface ProductRouteDependencies { repositoryId: string, filters: RepositoryIndexFilters, ): ReturnType + findPrototypeDetail( + repositoryId: string, + prototypeId: string, + buildNumber?: number, + ): ReturnType findRepositoryAuthors( repositoryId: string, filters: RepositoryAuthorsFilters, diff --git a/hub/server/routes/api/repositories/prototypes/show/get/index.db.test.ts b/hub/server/routes/api/repositories/prototypes/show/get/index.db.test.ts new file mode 100644 index 00000000..06c1e116 --- /dev/null +++ b/hub/server/routes/api/repositories/prototypes/show/get/index.db.test.ts @@ -0,0 +1,384 @@ +import { randomUUID } from "node:crypto" +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, vi } from "vitest" + +import { createApp } from "../../../../../../create-app" +import { prisma } from "../../../../../../integrations/prisma" +import { createProductRouteDependencies } from "../../../../product/create-product-route-dependencies" +import type { AuthenticationRouteDependencies } from "../../../../auth/types" + +async function withServer( + restore: AuthenticationRouteDependencies["currentSession"]["restore"], + operation: (baseUrl: string) => Promise, +) { + const authentication: AuthenticationRouteDependencies = { + currentSession: { restore }, + signOut: { applicationOrigin: "http://127.0.0.1:5173", secureCookie: false, end: vi.fn() }, + } + const server = createServer( + createApp({ authentication, product: createProductRouteDependencies() }), + ) + server.listen(0, "127.0.0.1") + await once(server, "listening") + try { + const { port } = server.address() as AddressInfo + await operation(`http://127.0.0.1:${port}`) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } +} + +async function createBuild( + prototypeId: string, + number: number, + state: "failed" | "queued" | "running" | "successful" | "superseded", + workflowCreatedAt: string | null, + createdAt: string, +) { + return prisma.build.create({ + data: { + prototypeId, + number, + state, + workflowCreatedAt: workflowCreatedAt ? new Date(workflowCreatedAt) : null, + createdAt: new Date(createdAt), + }, + }) +} + +describe("prototype detail API", () => { + it("returns stored non-GitHub author profiles", async () => { + const repositoryId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/design", defaultBranch: "main" }, + }) + const authors = [ + { login: "sam@example.test", name: "Sam Rivera" }, + { login: "lee@example.test", name: null }, + ] + const prototypeIds: string[] = [] + for (const [index, author] of authors.entries()) { + const user = await prisma.user.create({ + data: { + profile: { + create: { provider: "entra", login: author.login, name: author.name }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + repositoryId, + authorId: user.id, + branchName: `prototype/author-${index}`, + name: `Author ${index}`, + }, + }) + prototypeIds.push(prototype.id) + } + const restore = vi.fn().mockResolvedValue({ + membershipId: randomUUID(), + userId: randomUUID(), + provider: "github", + login: "viewer", + name: "Viewer", + role: "MEMBER", + }) + + await withServer(restore, async (baseUrl) => { + for (const [index, author] of authors.entries()) { + const response = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeIds[index]}`, + { headers: { cookie: "hub_session=member-session" } }, + ) + expect(response.status).toBe(200) + const detail = (await response.json()) as { prototype: unknown } + expect(detail.prototype).toMatchObject({ + authorProvider: "entra", + authorName: author.name, + authorLogin: author.login, + }) + } + }) + }) + + it("selects the latest successful Build by Rails chronology", async () => { + const repositoryId = randomUUID() + const prototypeId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/design", defaultBranch: "main" }, + }) + await prisma.prototype.create({ + data: { + id: prototypeId, + repositoryId, + branchName: "prototype/checkout", + name: "Checkout", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + }) + await createBuild(prototypeId, 2, "successful", "2026-10-08T00:00:00Z", "2026-10-20T00:00:00Z") + await createBuild(prototypeId, 4, "successful", "2026-10-09T00:00:00Z", "2026-10-10T00:00:00Z") + await createBuild(prototypeId, 5, "successful", "2026-10-09T00:00:00Z", "2026-10-01T00:00:00Z") + await createBuild(prototypeId, 6, "successful", null, "2026-10-08T00:00:00Z") + await createBuild(prototypeId, 20, "failed", "2026-10-10T00:00:00Z", "2026-10-10T00:00:00Z") + const restore = vi.fn().mockResolvedValue({ + membershipId: randomUUID(), + userId: randomUUID(), + provider: "github", + login: "mona", + name: "Mona", + role: "MEMBER", + }) + + await withServer(restore, async (baseUrl) => { + const headers = { cookie: "hub_session=member-session" } + const defaultResponse = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}`, + { headers }, + ) + expect(defaultResponse.status).toBe(200) + const defaultDetail = (await defaultResponse.json()) as { + selectedBuild: { number: number } | null + } + expect(defaultDetail.selectedBuild).toMatchObject({ number: 5 }) + + const selectedResponse = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}?build=2`, + { headers }, + ) + expect(selectedResponse.status).toBe(200) + const selectedDetail = (await selectedResponse.json()) as { + selectedBuild: { + number: number + commitSha: string | null + commitAuthorLogin: string | null + } | null + } + expect(selectedDetail.selectedBuild).toMatchObject({ + number: 2, + commitSha: null, + commitAuthorLogin: null, + }) + }) + }) + + it("keeps detail visibility independent of registration and branch existence", async () => { + const repositoryId = randomUUID() + const unregisteredId = randomUUID() + const deletedBranchId = randomUUID() + const defaultBranchId = randomUUID() + const internalId = randomUUID() + const installWorkflowId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/design", defaultBranch: "main" }, + }) + await prisma.prototype.createMany({ + data: [ + { + id: unregisteredId, + repositoryId, + branchName: "prototype/unregistered", + }, + { + id: deletedBranchId, + repositoryId, + branchName: "prototype/deleted", + name: "Deleted branch", + registeredAt: new Date("2026-10-01T00:00:00Z"), + branchDeletedAt: new Date("2026-10-02T00:00:00Z"), + }, + { + id: defaultBranchId, + repositoryId, + branchName: "main", + name: "Default branch", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + { + id: internalId, + repositoryId, + branchName: "replica/internal", + name: "Internal prototype", + replica: true, + }, + { + id: installWorkflowId, + repositoryId, + branchName: "replica/install-workflow", + name: "Install workflow", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + ], + }) + const restore = vi.fn().mockResolvedValue({ + membershipId: randomUUID(), + userId: randomUUID(), + provider: "entra", + login: "member", + name: "Member", + role: "MEMBER", + }) + + await withServer(restore, async (baseUrl) => { + const headers = { cookie: "hub_session=member-session" } + const unregisteredResponse = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${unregisteredId}`, + { headers }, + ) + expect(unregisteredResponse.status).toBe(200) + const unregisteredDetail = (await unregisteredResponse.json()) as { + prototype: { name: string; branchName: string } + } + expect(unregisteredDetail.prototype).toMatchObject({ + name: "prototype/unregistered", + branchName: "prototype/unregistered", + }) + + const deletedResponse = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${deletedBranchId}`, + { headers }, + ) + expect(deletedResponse.status).toBe(200) + + for (const hiddenId of [defaultBranchId, internalId, installWorkflowId]) { + const hiddenResponse = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${hiddenId}`, + { headers }, + ) + expect(hiddenResponse.status).toBe(404) + } + }) + }) + + it("returns no selected Build when the prototype has no successful Build", async () => { + const repositoryId = randomUUID() + const prototypeId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/design", defaultBranch: "main" }, + }) + await prisma.prototype.create({ + data: { + id: prototypeId, + repositoryId, + branchName: "prototype/no-build", + name: "No Build", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + }) + await createBuild(prototypeId, 1, "failed", "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z") + await createBuild(prototypeId, 2, "queued", null, "2026-10-03T00:00:00Z") + const restore = vi.fn().mockResolvedValue({ + membershipId: randomUUID(), + userId: randomUUID(), + provider: "github", + login: "mona", + name: "Mona", + role: "MEMBER", + }) + + await withServer(restore, async (baseUrl) => { + const response = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}`, + { headers: { cookie: "hub_session=member-session" } }, + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ selectedBuild: null }) + }) + }) + + it("returns 404 for invalid, unavailable, or unsuccessful Build selections", async () => { + const repositoryId = randomUUID() + const prototypeId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/design", defaultBranch: "main" }, + }) + await prisma.prototype.create({ + data: { + id: prototypeId, + repositoryId, + branchName: "prototype/build-selection", + name: "Build selection", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + }) + await createBuild(prototypeId, 1, "successful", "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z") + await createBuild(prototypeId, 2, "failed", "2026-10-03T00:00:00Z", "2026-10-03T00:00:00Z") + const restore = vi.fn().mockResolvedValue({ + membershipId: randomUUID(), + userId: randomUUID(), + provider: "entra", + login: "member", + name: "Member", + role: "MEMBER", + }) + + await withServer(restore, async (baseUrl) => { + const headers = { cookie: "hub_session=member-session" } + for (const selection of ["abc", "0", "-1", "1.5", "99", "2"]) { + const response = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}?build=${selection}`, + { headers }, + ) + expect(response.status, `build=${selection}`).toBe(404) + } + }) + }) + + it("requires a current session and revalidates membership before returning detail", async () => { + const repositoryId = randomUUID() + const prototypeId = randomUUID() + await prisma.repository.create({ + data: { id: repositoryId, nameWithOwner: "team/private", defaultBranch: "main" }, + }) + await prisma.prototype.create({ + data: { + id: prototypeId, + repositoryId, + branchName: "prototype/private", + name: "Private prototype", + registeredAt: new Date("2026-10-01T00:00:00Z"), + }, + }) + const member = { + membershipId: randomUUID(), + userId: randomUUID(), + provider: "github", + login: "mona", + name: "Mona", + role: "MEMBER" as const, + } + + await withServer(vi.fn().mockResolvedValue(null), async (baseUrl) => { + const headerSets: Record[] = [{}, { cookie: "hub_session=expired-session" }] + for (const headers of headerSets) { + const response = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}`, + { headers }, + ) + expect(response.status).toBe(401) + expect(await response.text()).not.toContain("Private prototype") + } + }) + + const restore = vi + .fn() + .mockResolvedValueOnce(member) + .mockResolvedValueOnce({ + ...member, + membershipId: randomUUID(), + }) + await withServer(restore, async (baseUrl) => { + const response = await fetch( + `${baseUrl}/api/repositories/${repositoryId}/prototypes/${prototypeId}`, + { headers: { cookie: "hub_session=member-session" } }, + ) + expect(response.status).toBe(401) + expect(await response.text()).not.toContain("Private prototype") + expect(restore).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/hub/server/routes/api/repositories/prototypes/show/get/index.ts b/hub/server/routes/api/repositories/prototypes/show/get/index.ts new file mode 100644 index 00000000..2c6b2006 --- /dev/null +++ b/hub/server/routes/api/repositories/prototypes/show/get/index.ts @@ -0,0 +1,75 @@ +import type { RequestHandler } from "express" +import { z } from "zod" + +import { hubSessionCookieName } from "../../../../../../integrations/express/hub-session-cookie-name/index.ts" +import { readCookie } from "../../../../../../integrations/express/read-cookie/index.ts" +import type { CurrentSessionDependencies } from "../../../../auth/types.ts" +import { readProductSession } from "../../../../product/read-product-session/index.ts" +import type { ProductRouteDependencies } from "../../../../product/types.ts" + +const querySchema = z + .object({ + build: z + .string() + .regex(/^[1-9]\d*$/) + .optional(), + view: z.enum(["gallery", "list", "minimized"]).optional(), + }) + .strict() + +export function get( + session: CurrentSessionDependencies, + product: ProductRouteDependencies, +): RequestHandler { + return async (request, response, next) => { + try { + const cookie = readCookie(request, hubSessionCookieName) + const initial = await readProductSession(cookie, session) + if (!initial) { + response.status(401).json({ error: "Sign in to continue." }) + return + } + + const repositoryId = z.uuid().safeParse(request.params.repositoryId) + const prototypeId = z.uuid().safeParse(request.params.prototypeId) + if (!repositoryId.success || !prototypeId.success) { + response.status(400).json({ error: "Enter a valid prototype address." }) + return + } + + const query = querySchema.safeParse(request.query) + if (!query.success) { + const status = Object.hasOwn(request.query, "build") ? 404 : 400 + response.status(status).json({ + error: + status === 404 ? "Successful Build not found." : "The prototype request is invalid.", + }) + return + } + const buildNumber = query.data.build ? Number(query.data.build) : undefined + if (buildNumber !== undefined && !Number.isSafeInteger(buildNumber)) { + response.status(404).json({ error: "Successful Build not found." }) + return + } + + const detail = await product.findPrototypeDetail( + repositoryId.data, + prototypeId.data, + buildNumber, + ) + const current = await readProductSession(cookie, session) + if (!current || current.membershipId !== initial.membershipId) { + response.status(401).json({ error: "Sign in to continue." }) + return + } + if (!detail || (buildNumber !== undefined && !detail.selectedBuild)) { + response.status(404).json({ error: "Prototype or successful Build not found." }) + return + } + + response.json({ ...detail, showPrototypeInfo: false }) + } catch (error) { + next(error) + } + } +} diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 9ac261f3..bf2d21e2 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -14,6 +14,7 @@ import { get as handleApiReadyGet } from "./api/ready/get/index.ts" import { get as handleWorkspaceGet } from "./api/workspace/get/index.ts" import { get as handleRepositoryAuthorsGet } from "./api/repositories/authors/get/index.ts" import { get as handleRepositoryGet } from "./api/repositories/show/get/index.ts" +import { get as handlePrototypeGet } from "./api/repositories/prototypes/show/get/index.ts" import { get as handleAdministratorsGet } from "./api/administrators/get/index.ts" import { get as handleAdministratorCandidatesGet } from "./api/administrators/candidates/get/index.ts" import { post as handleAdministratorAuthorizationStartPost } from "./api/administrators/authorizations/start/post/index.ts" @@ -42,6 +43,10 @@ export function routes(app: Express, dependencies: RouteDependencies = {}): void "/api/repositories/:repositoryId/authors", handleRepositoryAuthorsGet(authentication.currentSession, product), ) + app.get( + "/api/repositories/:repositoryId/prototypes/:prototypeId", + handlePrototypeGet(authentication.currentSession, product), + ) } if (authentication.administrators) { app.get("/api/security-audits", handleSecurityAuditsGet(authentication.administrators)) From 7f726fc28d067d3930e6fcf78f7dcbca73e39440 Mon Sep 17 00:00:00 2001 From: Nishiki Liu Date: Thu, 12 Nov 2026 14:26:00 -0800 Subject: [PATCH 2/4] feat(hub): connect real prototype details Index cards now open real prototype details. Unavailable history, Build detail, and sharing actions stay hidden in real mode, and non-GitHub authors are shown without GitHub links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 335a01a0-881c-4159-bcf9-5f19b9b66bd4 --- hub/client/application/hub/contracts/index.ts | 30 +++- .../hub/mocks/create-mock-store/index.ts | 7 +- .../hub/read-prototype-query/index.ts | 3 +- .../hub/requests/get-prototype/index.test.ts | 2 +- .../application/hub/routes/journey.test.tsx | 89 +++++++++++ .../hub/routes/real-prototype-index.test.tsx | 149 +++++++++++++++++- .../hub/screens/prototype-screen/index.tsx | 5 +- .../hub/screens/repository-screen/index.tsx | 2 +- .../view-models/build-history-data/index.ts | 54 ++++--- .../hub/view-models/build-show-data/index.ts | 23 ++- .../view-models/prototype-show-data/index.ts | 20 ++- .../view-models/prototype-workspace/index.ts | 30 +++- .../view-models/repository-show-data/index.ts | 7 +- .../prototype-workspace-header/index.tsx | 11 +- .../prototype-workspace-header/types.ts | 3 +- hub/client/pages/builds-index-page/index.tsx | 2 +- hub/client/pages/builds-index-page/types.ts | 4 +- hub/client/pages/builds-show-page/index.tsx | 6 +- hub/client/pages/builds-show-page/types.ts | 2 +- .../pages/prototypes-show-page/stories.tsx | 31 +++- hub/docs/architecture.md | 4 +- hub/docs/development.md | 2 +- hub/docs/frontend.md | 12 +- 23 files changed, 425 insertions(+), 73 deletions(-) diff --git a/hub/client/application/hub/contracts/index.ts b/hub/client/application/hub/contracts/index.ts index bd057deb..f6b428d4 100644 --- a/hub/client/application/hub/contracts/index.ts +++ b/hub/client/application/hub/contracts/index.ts @@ -35,8 +35,16 @@ export const buildSchema = z.object({ repositoryId: recordIdentifierSchema, prototypeId: recordIdentifierSchema, state: buildStateSchema, - commitSha: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i), - commitAuthorLogin: z.string().min(1), + commitSha: z + .string() + .regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i) + .nullable(), + commitAuthorProvider: z.string().min(1).nullable().optional(), + commitAuthorIssuer: z.string().min(1).nullable().optional(), + commitAuthorProviderUserId: z.string().min(1).nullable().optional(), + commitAuthorLogin: z.string().nullable(), + commitAuthorName: z.string().nullable().optional(), + commitAuthorAvatarUrl: httpUrl.nullable().optional(), createdAt: timestamp, startedAt: timestamp.nullable(), finishedAt: timestamp.nullable(), @@ -52,7 +60,9 @@ export const prototypeSchema = z.object({ name: z.string().min(1), branchName: z.string().min(1), description: z.string().nullable(), - authorLogin: z.string().min(1), + authorLogin: z.string().min(1).nullable(), + authorName: z.string().nullable().optional(), + authorProvider: z.string().min(1).nullable().optional(), canEdit: z.boolean(), latestBuild: z.object({ number: identifier, state: buildStateSchema }).nullable().optional(), builds: z.array(buildSchema), @@ -147,6 +157,14 @@ export const routeQuerySchema = z.object({ build: z.coerce.number().int().positive().optional(), }) -export const prototypeQuerySchema = routeQuerySchema.pick({ build: true }).extend({ - view: z.enum(["gallery", "list", "minimized"]).optional(), -}) +export const prototypeQuerySchema = z + .object({ + build: z + .string() + .regex(/^[1-9]\d*$/) + .transform(Number) + .pipe(z.number().int()) + .optional(), + view: z.enum(["gallery", "list", "minimized"]).optional(), + }) + .strict() diff --git a/hub/client/application/hub/mocks/create-mock-store/index.ts b/hub/client/application/hub/mocks/create-mock-store/index.ts index ac4c531a..f8c7f2a8 100644 --- a/hub/client/application/hub/mocks/create-mock-store/index.ts +++ b/hub/client/application/hub/mocks/create-mock-store/index.ts @@ -82,14 +82,17 @@ export function createMockStore({ origin, seed = createFixtureSeed() }: Options) throw new Error("The mock fixture contains duplicate records.") } for (const prototype of data.prototypes) { - if (!repositories.has(prototype.repositoryId) || !userLogins.has(prototype.authorLogin)) { + if ( + !repositories.has(prototype.repositoryId) || + (prototype.authorLogin !== null && !userLogins.has(prototype.authorLogin)) + ) { throw new Error("A mock prototype refers to a missing repository or user.") } } const builds = data.builds.map((build) => { if ( !prototypeKeys.has(`${build.repositoryId}/${build.prototypeId}`) || - !userLogins.has(build.commitAuthorLogin) + (build.commitAuthorLogin !== null && !userLogins.has(build.commitAuthorLogin)) ) { throw new Error("A mock build refers to a missing prototype or commit author.") } diff --git a/hub/client/application/hub/read-prototype-query/index.ts b/hub/client/application/hub/read-prototype-query/index.ts index 246312c6..85eff9ce 100644 --- a/hub/client/application/hub/read-prototype-query/index.ts +++ b/hub/client/application/hub/read-prototype-query/index.ts @@ -9,9 +9,10 @@ export function readPrototypeQuery(request: Request): PrototypeQuery { }) if (!result.success) { + const invalidBuild = result.error.issues.some((issue) => issue.path[0] === "build") throw new Response( result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" "), - { status: 400 }, + { status: invalidBuild ? 404 : 400 }, ) } return result.data diff --git a/hub/client/application/hub/requests/get-prototype/index.test.ts b/hub/client/application/hub/requests/get-prototype/index.test.ts index ce8fecd9..d766009d 100644 --- a/hub/client/application/hub/requests/get-prototype/index.test.ts +++ b/hub/client/application/hub/requests/get-prototype/index.test.ts @@ -68,7 +68,7 @@ describe("getPrototype()", () => { const result = Promise.resolve().then(() => getPrototype(request, "2001", "1")) await expect(result).rejects.toBeInstanceOf(Response) - await expect(result).rejects.toMatchObject({ status: 400 }) + await expect(result).rejects.toMatchObject({ status: 404 }) }, ) diff --git a/hub/client/application/hub/routes/journey.test.tsx b/hub/client/application/hub/routes/journey.test.tsx index 3d67e0be..ede541ae 100644 --- a/hub/client/application/hub/routes/journey.test.tsx +++ b/hub/client/application/hub/routes/journey.test.tsx @@ -117,10 +117,17 @@ describe("Hub repository journey", () => { expect(router.state.location.pathname).toBe(`/repositories/${repositoryId}`) await router.settle(() => fireEvent.click(welcome)) expect(await screen.findByTitle(`Welcome, Build ${latestBuild} preview`)).toBeInTheDocument() + expect(screen.getByLabelText("By @octocat").closest("a")).toHaveAttribute( + "href", + "https://github.com/octocat", + ) expect( screen.getByRole("textbox", { name: "Preview URL" }).value, ).not.toMatch(/mock|synthetic|simulat(?:ed|ion)/i) fireEvent.click(screen.getByRole("button", { name: "More actions" })) + expect( + screen.getByRole("menuitem", { name: "Share links" }).querySelector("a"), + ).toHaveAttribute("href", `/repositories/${repositoryId}/prototypes/${prototypeId}/share-links`) const history = screen.getByRole("menuitem", { name: "History" }).querySelector("a") if (!history) throw new Error("The History action has no link.") await router.settle(() => fireEvent.click(history)) @@ -134,6 +141,10 @@ describe("Hub repository journey", () => { expect(await screen.findByRole("heading", { name: "Build 2", level: 2 })).toBeInTheDocument() expect(screen.getByText("Failed")).toBeInTheDocument() + expect(screen.getByText("By @mona").closest("a")).toHaveAttribute( + "href", + "https://github.com/mona", + ) if (!failedBuild.failureSummary) throw new Error("The failed Build needs an explanation.") expect(screen.getByText(failedBuild.failureSummary)).toBeInTheDocument() expect(router.state.location.pathname).toBe( @@ -141,6 +152,84 @@ describe("Hub repository journey", () => { ) }, 15_000) + it("shows commit authors in Build history and details", async () => { + const store = createMockStore({ origin: window.location.origin }) + const history = store.getBuildHistory("2001", "1") + const authors = [ + { + provider: "entra", + login: "sam@example.test", + name: "Sam Rivera", + }, + { + provider: "entra", + login: "lee@example.test", + name: null, + }, + { + provider: "github", + login: "mona", + name: "Mona Lisa", + }, + { provider: null, login: null, name: null }, + { provider: null, login: "legacy-author", name: null }, + ] + const builds = authors.map((author, index) => ({ + ...history.builds[index]!, + commitAuthorProvider: author.provider, + commitAuthorLogin: author.login, + commitAuthorName: author.name, + })) + const server = setupServer(...createMockHandlers(store)) + server.use( + http.get(`${window.location.origin}/api/repositories/2001/prototypes/1/builds`, () => + HttpResponse.json({ ...history, builds }), + ), + http.get( + `${window.location.origin}/api/repositories/2001/prototypes/1/builds/:number`, + ({ params }) => { + const build = builds.find(({ number }) => String(number) === params.number) + if (!build) return new HttpResponse(null, { status: 404 }) + return HttpResponse.json({ ...store.getBuild("2001", "1", build.number), build }) + }, + ), + ) + const router = await renderHubFlow(server, "/repositories/2001/prototypes/1/builds") + + expect(screen.getByText("By Sam Rivera").closest("a")).toBeNull() + expect(screen.getByText("By lee@example.test").closest("a")).toBeNull() + expect(screen.getByText("By @mona").closest("a")).toHaveAttribute( + "href", + "https://github.com/mona", + ) + expect(screen.getByText("Unknown author").closest("a")).toBeNull() + expect(screen.getByText("By legacy-author").closest("a")).toBeNull() + + await router.settle(() => + router.navigate(`/repositories/2001/prototypes/1/builds/${builds[0]!.number}`), + ) + expect(screen.getByText("By Sam Rivera").closest("a")).toBeNull() + await router.settle(() => + router.navigate(`/repositories/2001/prototypes/1/builds/${builds[1]!.number}`), + ) + expect(screen.getByText("By lee@example.test").closest("a")).toBeNull() + await router.settle(() => + router.navigate(`/repositories/2001/prototypes/1/builds/${builds[2]!.number}`), + ) + expect(screen.getByText("By @mona").closest("a")).toHaveAttribute( + "href", + "https://github.com/mona", + ) + await router.settle(() => + router.navigate(`/repositories/2001/prototypes/1/builds/${builds[3]!.number}`), + ) + expect(screen.getByText("Unknown author").closest("a")).toBeNull() + await router.settle(() => + router.navigate(`/repositories/2001/prototypes/1/builds/${builds[4]!.number}`), + ) + expect(screen.getByText("By legacy-author").closest("a")).toBeNull() + }, 15_000) + it("retains a failed edit draft and publishes a successful retry consistently", async () => { const store = createMockStore({ origin: window.location.origin }) const server = setupServer(...createMockHandlers(store)) diff --git a/hub/client/application/hub/routes/real-prototype-index.test.tsx b/hub/client/application/hub/routes/real-prototype-index.test.tsx index 902c8acd..d6de02f6 100644 --- a/hub/client/application/hub/routes/real-prototype-index.test.tsx +++ b/hub/client/application/hub/routes/real-prototype-index.test.tsx @@ -88,13 +88,15 @@ async function renderApplication(path: string) { } describe("real prototype index", () => { - it("opens the first repository after sign-in without offering prototype details", async () => { + it("opens prototype details from the real index after sign-in", async () => { vi.stubEnv("MODE", "development") vi.stubEnv("DEV", true) const repositoryId = "11111111-1111-4111-8111-111111111111" const prototypeId = "22222222-2222-4222-8222-222222222222" const unknownPrototypeId = "33333333-3333-4333-8333-333333333333" const entraPrototypeId = "88888888-8888-4888-8888-888888888888" + const fallbackPrototypeId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + const unknownProviderPrototypeId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" const userId = "44444444-4444-4444-8444-444444444444" const authorId = "77777777-7777-4777-8777-777777777777" const entraAuthorId = "99999999-9999-4999-8999-999999999999" @@ -112,6 +114,26 @@ describe("real prototype index", () => { manageSharing: false, administerHub: false, } + const successfulBuild = { + number: 1, + repositoryId, + prototypeId, + state: "successful", + commitSha: null, + commitAuthorProvider: "github", + commitAuthorIssuer: "https://github.com", + commitAuthorProviderUserId: "mona-id", + commitAuthorLogin: null, + commitAuthorName: null, + commitAuthorAvatarUrl: null, + createdAt: "2026-11-10T12:00:00.000Z", + startedAt: null, + finishedAt: null, + previewUrl: null, + desktopScreenshotUrl: null, + narrowScreenshotUrl: null, + failureSummary: null, + } let signedIn = false const requests: string[] = [] const server = setupServer( @@ -212,6 +234,85 @@ describe("real prototype index", () => { }) : new HttpResponse(null, { status: 401 }), ), + http.get( + `${window.location.origin}/api/repositories/${repositoryId}/prototypes/${prototypeId}`, + ({ request }) => { + const selectedBuild = + new URL(request.url).searchParams.get("build") === "3" + ? { ...successfulBuild, number: 3 } + : successfulBuild + return HttpResponse.json({ + repository, + prototype: { + id: prototypeId, + repositoryId, + name: "Welcome flow", + branchName: "prototype/welcome", + description: "A welcoming start.", + authorProvider: "github", + authorLogin: "mona", + authorName: "Mona", + canEdit: false, + latestBuild: { number: 1, state: "successful" }, + builds: [ + successfulBuild, + { ...successfulBuild, number: 2, state: "failed" }, + { ...successfulBuild, number: 3 }, + ], + }, + selectedBuild, + showPrototypeInfo: false, + }) + }, + ), + http.get( + `${window.location.origin}/api/repositories/${repositoryId}/prototypes/:prototypeId`, + ({ params }) => { + const authors: Record< + string, + { authorProvider: string | null; authorLogin: string | null; authorName: string | null } + > = { + [entraPrototypeId]: { + authorProvider: "entra", + authorLogin: "mona@example.test", + authorName: "Mona", + }, + [fallbackPrototypeId]: { + authorProvider: "entra", + authorLogin: "sam@example.test", + authorName: null, + }, + [unknownProviderPrototypeId]: { + authorProvider: null, + authorLogin: "legacy-author", + authorName: null, + }, + [unknownPrototypeId]: { authorProvider: null, authorLogin: null, authorName: null }, + } + const author = authors[String(params.prototypeId)] + if (!author) return new HttpResponse(null, { status: 404 }) + return HttpResponse.json({ + repository, + prototype: { + id: params.prototypeId, + repositoryId, + name: "Design", + branchName: "prototype/design", + description: null, + ...author, + canEdit: false, + latestBuild: null, + builds: [], + }, + selectedBuild: null, + showPrototypeInfo: false, + }) + }, + ), + http.get( + `${window.location.origin}/api/repositories/${repositoryId}/prototypes/:prototypeId/review-comments`, + () => new HttpResponse(null, { status: 404 }), + ), ) const searches: string[] = [] server.events.on("request:start", ({ request }) => { @@ -241,7 +342,7 @@ describe("real prototype index", () => { expect(within(results).getByText("Unattributed flow")).toBeVisible() expect(within(results).getByText("Unknown author").closest("a")).toBeNull() expect(within(results).getByText("By Mona").closest("a")).toBeNull() - expect(detailLinks(results, repositoryId)).toHaveLength(0) + expect(detailLinks(results, repositoryId)).toHaveLength(3) expect(githubAuthorPaths(results)).toContain("/mona") expect(githubAuthorPaths(results).every((path) => path === "/mona")).toBe(true) expect(screen.getByRole("button", { name: "User: All users" })).toBeInTheDocument() @@ -261,7 +362,7 @@ describe("real prototype index", () => { expect(within(listResults).getByText("Unattributed flow")).toBeVisible() expect(within(listResults).getByText("Unknown author").closest("a")).toBeNull() expect(within(listResults).getByText("Mona").closest("a")).toBeNull() - expect(detailLinks(listResults, repositoryId)).toHaveLength(0) + expect(detailLinks(listResults, repositoryId)).toHaveLength(3) expect(githubAuthorPaths(listResults)).toContain("/mona") expect(githubAuthorPaths(listResults).every((path) => path === "/mona")).toBe(true) expect(within(listResults).getByRole("columnheader", { name: "Build" })).toBeVisible() @@ -273,6 +374,48 @@ describe("real prototype index", () => { fireEvent.change(search, { target: { value: " welcome " } }) fireEvent.keyDown(search, { key: "Enter" }) await waitFor(() => expect(searches).toContain(`/api/repositories/${repositoryId}?q=welcome`)) + + fireEvent.click(screen.getByRole("link", { name: "Welcome flow" })) + expect( + await screen.findByText("Preview hosting is not available for this Build."), + ).toBeVisible() + expect(router.state.location.pathname).toBe( + `/repositories/${repositoryId}/prototypes/${prototypeId}`, + ) + expect(screen.getByLabelText("By @mona").closest("a")).toHaveAttribute( + "href", + "https://github.com/mona", + ) + + expect(screen.queryByRole("button", { name: "More actions" })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: /Build 1.*v1/i })) + const successful = screen.getByRole("menuitem", { name: /v3/i }).querySelector("a") + expect(successful).toHaveAttribute( + "href", + `/repositories/${repositoryId}/prototypes/${prototypeId}?build=3`, + ) + expect(screen.queryByRole("menuitem", { name: /v2/i })?.querySelector("a")).toBeFalsy() + if (!successful) throw new Error("The successful Build needs a link.") + fireEvent.click(successful) + await waitFor(() => expect(router.state.location.search).toBe("?build=3")) + expect(await screen.findByText("Build 3 has no preview")).toBeVisible() + await act(async () => + router.navigate(`/repositories/${repositoryId}/prototypes/${entraPrototypeId}`), + ) + expect((await screen.findByLabelText("By Mona")).closest("a")).toBeNull() + await act(async () => + router.navigate(`/repositories/${repositoryId}/prototypes/${fallbackPrototypeId}`), + ) + expect((await screen.findByLabelText("By sam@example.test")).closest("a")).toBeNull() + await act(async () => + router.navigate(`/repositories/${repositoryId}/prototypes/${unknownProviderPrototypeId}`), + ) + expect((await screen.findByLabelText("By legacy-author")).closest("a")).toBeNull() + await act(async () => + router.navigate(`/repositories/${repositoryId}/prototypes/${unknownPrototypeId}`), + ) + expect((await screen.findByLabelText("Unknown author")).closest("a")).toBeNull() }, 15_000) it("shows a repository failure instead of an empty index", async () => { diff --git a/hub/client/application/hub/screens/prototype-screen/index.tsx b/hub/client/application/hub/screens/prototype-screen/index.tsx index 7d6c5f1c..afeb650a 100644 --- a/hub/client/application/hub/screens/prototype-screen/index.tsx +++ b/hub/client/application/hub/screens/prototype-screen/index.tsx @@ -32,7 +32,10 @@ export function PrototypeScreen({ const iframeRef = useRef(null) const { resolvedColorMode } = useHubTheme() const session = useRouteLoaderData("root") - const pageData = prototypeShowData(workspace, data, new URLSearchParams(location.search)) + const mock = import.meta.env.DEV && import.meta.env.MODE === "mock" + const pageData = prototypeShowData(workspace, data, new URLSearchParams(location.search), { + real: !mock, + }) pageData.reviewReadOnly = !session?.capabilities.reviewPrototypes if (pageData.reviewReadOnly) { pageData.reviewComments = { diff --git a/hub/client/application/hub/screens/repository-screen/index.tsx b/hub/client/application/hub/screens/repository-screen/index.tsx index 122055e4..19289cb8 100644 --- a/hub/client/application/hub/screens/repository-screen/index.tsx +++ b/hub/client/application/hub/screens/repository-screen/index.tsx @@ -47,7 +47,7 @@ export function RepositoryScreen({ return ( changeFilter("view", value === "gallery" ? "" : value)} controls={{ diff --git a/hub/client/application/hub/view-models/build-history-data/index.ts b/hub/client/application/hub/view-models/build-history-data/index.ts index 697f7063..da00733f 100644 --- a/hub/client/application/hub/view-models/build-history-data/index.ts +++ b/hub/client/application/hub/view-models/build-history-data/index.ts @@ -25,24 +25,42 @@ export function buildHistoryData( search, surface: "history", }), - builds: data.builds.map((build) => ({ - author: { - avatarUrl: - workspace.viewer.login === build.commitAuthorLogin ? workspace.viewer.avatarUrl : null, - label: `By @${build.commitAuthorLogin}`, - url: `https://github.com/${encodeURIComponent(build.commitAuthorLogin)}`, - }, - commitLabel: `Commit ${build.commitSha.slice(0, 7)}`, - commitSha: build.commitSha, - createdAt: build.createdAt, - createdLabel: `Created ${formatBuildTime(build.createdAt)}`, - number: build.number, - numberLabel: `Build ${build.number}`, - path: resourcePath(`${path}/${build.number}`, search), - shortCommitSha: build.commitSha.slice(0, 7), - state: build.state, - stateLabel: buildStateLabel(build.state), - })), + builds: data.builds.map((build) => { + const githubAuthor = + build.commitAuthorProvider === undefined || build.commitAuthorProvider === "github" + const authorName = + githubAuthor && build.commitAuthorLogin + ? `@${build.commitAuthorLogin}` + : build.commitAuthorName || build.commitAuthorLogin + return { + author: { + avatarUrl: + build.commitAuthorAvatarUrl ?? + (workspace.viewer.login === build.commitAuthorLogin && + (build.commitAuthorProvider === undefined || + build.commitAuthorProvider === workspace.viewer.provider) + ? workspace.viewer.avatarUrl + : null), + label: authorName ? `By ${authorName}` : "Unknown author", + url: + githubAuthor && build.commitAuthorLogin + ? `https://github.com/${encodeURIComponent(build.commitAuthorLogin)}` + : null, + }, + commitLabel: build.commitSha + ? `Commit ${build.commitSha.slice(0, 7)}` + : "Commit unavailable", + commitSha: build.commitSha, + createdAt: build.createdAt, + createdLabel: `Created ${formatBuildTime(build.createdAt)}`, + number: build.number, + numberLabel: `Build ${build.number}`, + path: resourcePath(`${path}/${build.number}`, search), + shortCommitSha: build.commitSha?.slice(0, 7) ?? null, + state: build.state, + stateLabel: buildStateLabel(build.state), + } + }), buildsHeading: "History", emptyLabel: "No Builds have been recorded for this prototype.", pageTitle: `${data.prototype.name} · Build history`, diff --git a/hub/client/application/hub/view-models/build-show-data/index.ts b/hub/client/application/hub/view-models/build-show-data/index.ts index 02b5115c..4c30b535 100644 --- a/hub/client/application/hub/view-models/build-show-data/index.ts +++ b/hub/client/application/hub/view-models/build-show-data/index.ts @@ -12,8 +12,20 @@ export function buildShowData( search: URLSearchParams, ): BuildsShowPageData { const build = data.build - const author = workspace.viewer.login === build.commitAuthorLogin ? workspace.viewer : null const hasScreenshots = Boolean(build.desktopScreenshotUrl && build.narrowScreenshotUrl) + const githubAuthor = + build.commitAuthorProvider === undefined || build.commitAuthorProvider === "github" + const authorName = + githubAuthor && build.commitAuthorLogin + ? `@${build.commitAuthorLogin}` + : build.commitAuthorName || build.commitAuthorLogin + const authorAvatarUrl = + build.commitAuthorAvatarUrl ?? + (workspace.viewer.login === build.commitAuthorLogin && + (build.commitAuthorProvider === undefined || + build.commitAuthorProvider === workspace.viewer.provider) + ? workspace.viewer.avatarUrl + : null) return { ...prototypeWorkspace({ @@ -25,9 +37,12 @@ export function buildShowData( surface: "build", }), commitAuthor: { - avatarUrl: author?.avatarUrl ?? null, - label: `By @${build.commitAuthorLogin}`, - url: `https://github.com/${encodeURIComponent(build.commitAuthorLogin)}`, + avatarUrl: authorAvatarUrl, + label: authorName ? `By ${authorName}` : "Unknown author", + url: + githubAuthor && build.commitAuthorLogin + ? `https://github.com/${encodeURIComponent(build.commitAuthorLogin)}` + : null, }, commitHeading: "Exact commit", commitSha: build.commitSha, diff --git a/hub/client/application/hub/view-models/prototype-show-data/index.ts b/hub/client/application/hub/view-models/prototype-show-data/index.ts index 9c011aa9..99b66830 100644 --- a/hub/client/application/hub/view-models/prototype-show-data/index.ts +++ b/hub/client/application/hub/view-models/prototype-show-data/index.ts @@ -11,6 +11,7 @@ export function prototypeShowData( workspace: WorkspaceResponse, data: PrototypeResponse, search: URLSearchParams, + options: { real?: boolean } = {}, ): PrototypesShowPageData { const selectedBuild = data.selectedBuild const previewBuild = @@ -26,6 +27,11 @@ export function prototypeShowData( return { ...workspaceData, + buildOptions: options.real + ? workspaceData.buildOptions.filter((build) => build.state === "successful") + : workspaceData.buildOptions, + historyPath: options.real ? undefined : workspaceData.historyPath, + shareLinksPath: options.real ? undefined : workspaceData.shareLinksPath, buildPinned: search.has("build"), controlsIdentity: data.controlsIdentity, prototypeName: data.prototype.name, @@ -57,8 +63,8 @@ export function prototypeShowData( ? `Build ${selectedBuild.number} has no preview` : "No preview available", emptyLabel: selectedBuild - ? `${buildStateLabel(selectedBuild.state)}. Build history remains available.` - : "This prototype has no preview yet. Build history remains available.", + ? "Preview hosting is not available for this Build." + : "This prototype has no successful Build yet.", previewUrlLabel: "Preview URL", copyPreviewUrlLabel: "Copy preview URL", copiedPreviewUrlLabel: "Copied preview URL", @@ -87,10 +93,12 @@ export function prototypeShowData( ? { number: previewBuild.number, detailsLabel: "Build details", - detailsPath: resourcePath( - `/repositories/${data.repository.id}/prototypes/${data.prototype.id}/builds/${previewBuild.number}`, - search, - ), + detailsPath: options.real + ? undefined + : resourcePath( + `/repositories/${data.repository.id}/prototypes/${data.prototype.id}/builds/${previewBuild.number}`, + search, + ), frameTitle: `${data.prototype.name}, Build ${previewBuild.number} preview`, previewUrl: previewBuild.previewUrl, } diff --git a/hub/client/application/hub/view-models/prototype-workspace/index.ts b/hub/client/application/hub/view-models/prototype-workspace/index.ts index e70675d3..d3ca8ded 100644 --- a/hub/client/application/hub/view-models/prototype-workspace/index.ts +++ b/hub/client/application/hub/view-models/prototype-workspace/index.ts @@ -27,9 +27,25 @@ export function prototypeWorkspace({ }): PrototypeWorkspaceData { const path = `/repositories/${repository.id}/prototypes/${prototype.id}` const builds = [...prototype.builds].sort((first, second) => second.number - first.number) - const latestBuild = builds[0] + const latestBuild = + prototype.latestBuild === undefined + ? builds[0] + : (builds.find(({ number }) => number === prototype.latestBuild?.number) ?? + prototype.latestBuild) const pinnedBuild = search.has("build") ? (selectedBuild?.number ?? null) : null - const author = workspace.viewer.login === prototype.authorLogin ? workspace.viewer : null + const githubAuthor = + prototype.authorProvider === undefined || prototype.authorProvider === "github" + const authorName = + githubAuthor && prototype.authorLogin + ? `@${prototype.authorLogin}` + : prototype.authorName || prototype.authorLogin + const author = + prototype.authorLogin && + workspace.viewer.login === prototype.authorLogin && + (prototype.authorProvider === undefined || + prototype.authorProvider === workspace.viewer.provider) + ? workspace.viewer + : null const previewPath = resourcePath(path, search, { build: selectedBuild?.previewUrl ? selectedBuild.number : null, }) @@ -38,8 +54,11 @@ export function prototypeWorkspace({ ...repositoryWorkspace(workspace, repository, search), author: { avatarUrl: author?.avatarUrl ?? null, - label: `By @${prototype.authorLogin}`, - url: `https://github.com/${encodeURIComponent(prototype.authorLogin)}`, + label: authorName ? `By ${authorName}` : "Unknown author", + url: + githubAuthor && prototype.authorLogin + ? `https://github.com/${encodeURIComponent(prototype.authorLogin)}` + : null, }, branchName: prototype.branchName, branchPath: resourcePath(path, search), @@ -48,7 +67,7 @@ export function prototypeWorkspace({ number: build.number, label: `v${build.number}`, path: - surface === "preview" && build.previewUrl + surface === "preview" && build.state === "successful" ? resourcePath(path, search, { build: build.number }) : resourcePath(`${path}/builds/${build.number}`, search), state: build.state, @@ -81,6 +100,7 @@ export function prototypeWorkspace({ }), prototypeInfoLabel: "About this prototype", repositoryPath: resourcePath(`/repositories/${repository.id}`, search), + selectedBuildNumber: selectedBuild?.number, selectedBuild: selectedBuild ? { number: selectedBuild.number, diff --git a/hub/client/application/hub/view-models/repository-show-data/index.ts b/hub/client/application/hub/view-models/repository-show-data/index.ts index 4f8fd426..04735c5e 100644 --- a/hub/client/application/hub/view-models/repository-show-data/index.ts +++ b/hub/client/application/hub/view-models/repository-show-data/index.ts @@ -9,7 +9,7 @@ export function repositoryShowData( workspace: WorkspaceResponse, data: RepositoryResponse, search: URLSearchParams, - options: { detailsAvailable?: boolean; real?: boolean } = {}, + options: { real?: boolean } = {}, ): RepositoriesShowPageData { const path = `/repositories/${data.repository.id}` const pageQuery = new URLSearchParams(search) @@ -68,10 +68,7 @@ export function repositoryShowData( name: prototype.name, branchName: prototype.branchName, description: prototype.description, - path: - options.detailsAvailable === false - ? null - : resourcePath(`${path}/prototypes/${prototype.id}`, search), + path: resourcePath(`${path}/prototypes/${prototype.id}`, search), author: { avatarUrl: prototypeAuthor?.avatarUrl ?? null, label: prototype.authorLogin ? `By ${authorName}` : authorName, diff --git a/hub/client/features/prototype-workspace-header/index.tsx b/hub/client/features/prototype-workspace-header/index.tsx index d09ba4b6..c8095e0a 100644 --- a/hub/client/features/prototype-workspace-header/index.tsx +++ b/hub/client/features/prototype-workspace-header/index.tsx @@ -47,7 +47,8 @@ export function PrototypeWorkspaceHeader({ }) const dark = resolvedColorMode === "dark" const themeLabel = dark ? data.useLightThemeLabel : data.useDarkThemeLabel - const selectedVersion = data.selectedBuild?.number ?? data.latestBuild?.number + const selectedVersion = + data.selectedBuildNumber ?? data.selectedBuild?.number ?? data.latestBuild?.number const menuLinks = data.browseReplica ? [] : [ @@ -60,7 +61,9 @@ export function PrototypeWorkspaceHeader({ }, ] : []), - { label: data.historyLabel, path: data.historyPath, icon: HistoryIcon }, + ...(data.historyPath + ? [{ label: data.historyLabel, path: data.historyPath, icon: HistoryIcon }] + : []), ...(data.selectedBuild?.detailsPath && data.selectedBuild.detailsLabel ? [ { @@ -214,7 +217,7 @@ export function PrototypeWorkspaceHeader({ key={build.number} build={build} path={path} - selected={build.number === data.selectedBuild?.number} + selected={build.number === selectedVersion} onKeyDown={activateMenuLink} /> ) @@ -228,7 +231,7 @@ export function PrototypeWorkspaceHeader({ ) : null} - {!data.browseReplica ? ( + {!data.browseReplica && menuLinks.length > 0 ? (
diff --git a/hub/client/features/prototype-workspace-header/types.ts b/hub/client/features/prototype-workspace-header/types.ts index 3132a6cd..6cfb5077 100644 --- a/hub/client/features/prototype-workspace-header/types.ts +++ b/hub/client/features/prototype-workspace-header/types.ts @@ -19,6 +19,7 @@ interface WorkspaceHeaderData { detailsLabel?: string detailsPath?: string } + selectedBuildNumber?: number useDarkThemeLabel: string useLightThemeLabel: string } @@ -40,7 +41,7 @@ export interface PrototypeWorkspaceHeaderData extends WorkspaceHeaderData { }> buildSelectionLabel: string historyLabel: string - historyPath: string + historyPath?: string moreActionsLabel: string shareLinksLabel?: string shareLinksPath?: string diff --git a/hub/client/pages/builds-index-page/index.tsx b/hub/client/pages/builds-index-page/index.tsx index 339feb8e..5daa654f 100644 --- a/hub/client/pages/builds-index-page/index.tsx +++ b/hub/client/pages/builds-index-page/index.tsx @@ -34,7 +34,7 @@ export function BuildsIndexPage({ data }: { data: BuildsIndexPageData }) { - + {build.commitLabel} diff --git a/hub/client/pages/builds-index-page/types.ts b/hub/client/pages/builds-index-page/types.ts index 1dffd6c3..691b657c 100644 --- a/hub/client/pages/builds-index-page/types.ts +++ b/hub/client/pages/builds-index-page/types.ts @@ -9,13 +9,13 @@ export interface BuildsIndexPageData extends PrototypeWorkspaceData { url: string | null } commitLabel: string - commitSha: string + commitSha: string | null createdAt: string createdLabel: string number: number numberLabel: string path: string - shortCommitSha: string + shortCommitSha: string | null state: BuildState stateLabel: string }> diff --git a/hub/client/pages/builds-show-page/index.tsx b/hub/client/pages/builds-show-page/index.tsx index 2a00927c..36abd936 100644 --- a/hub/client/pages/builds-show-page/index.tsx +++ b/hub/client/pages/builds-show-page/index.tsx @@ -49,7 +49,11 @@ export function BuildsShowPage({ data }: { data: BuildsShowPageData }) { - {data.commitSha} + {data.commitSha ? ( + {data.commitSha} + ) : ( + Commit unavailable + )} {data.commitAuthor.url ? ( diff --git a/hub/client/pages/builds-show-page/types.ts b/hub/client/pages/builds-show-page/types.ts index 23540ceb..ba525fd2 100644 --- a/hub/client/pages/builds-show-page/types.ts +++ b/hub/client/pages/builds-show-page/types.ts @@ -15,7 +15,7 @@ export interface BuildsShowPageData extends PrototypeWorkspaceData { url: string | null } commitHeading: string - commitSha: string + commitSha: string | null createdAt: string createdLabel: string desktopScreenshot?: BuildScreenshot diff --git a/hub/client/pages/prototypes-show-page/stories.tsx b/hub/client/pages/prototypes-show-page/stories.tsx index e5c64aa9..925f0f40 100644 --- a/hub/client/pages/prototypes-show-page/stories.tsx +++ b/hub/client/pages/prototypes-show-page/stories.tsx @@ -83,7 +83,7 @@ const data: PrototypesShowPageData = { closeLabel: "Close", collapseNavigationLabel: "Collapse sidebar", emptyHeading: "No successful Build yet", - emptyLabel: "Build history remains available while the workflow runs.", + emptyLabel: "This prototype has no successful Build yet.", expandNavigationLabel: "Expand sidebar", historyLabel: "History", historyPath: "/repositories/2001/prototypes/1/builds", @@ -223,6 +223,35 @@ export const Empty: Story = { }, } +export const PreviewHostingUnavailable: Story = { + args: { + data: { + ...data, + author: { avatarUrl: null, label: "By Mona", url: null }, + branchSubmissionMode: "disabled", + buildOptions: [ + { + number: 3, + label: "v3", + path: "/repositories/2001/prototypes/1?build=3", + state: "successful", + }, + ], + emptyHeading: "Build 3 has no preview", + emptyLabel: "Preview hosting is not available for this Build.", + buildSelectionLabel: "Build 3 · Successful", + detailsEditPath: undefined, + historyPath: undefined, + latestBuild: undefined, + newPrototypePath: null, + reviewReadOnly: true, + selectedBuild: undefined, + selectedBuildNumber: 3, + shareLinksPath: undefined, + }, + }, +} + export const Introduction: Story = { args: { initialInfoOpen: true }, } diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 6424f532..f5ce278d 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,7 +6,7 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check. The frontend connects the retained pages for local exploration. Storybook remains the presentation workbench. +The API responds to a health check and gives signed-in members read access to the seeded repository index and prototype details. The frontend connects the retained pages for local exploration. Storybook remains the presentation workbench. Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation with explicit GitHub and single-tenant Microsoft Entra sign-in adapters. A reusable deployment bootstrap secret can enroll a verified provider identity as a Hub administrator. Members can browse a seeded, read-only repository index. The app cannot yet discover real repositories, import Builds, host published previews, edit product data, or support live multi-user reviews. @@ -57,7 +57,7 @@ Imports follow the arrows above. Shared components, hooks, and utilities must no The theme provider wraps routes and their loading and error feedback. -- In real mode, signed-out visitors use the authentication shell. Signed-in members can browse the read-only repository index. Administrators can use the administrator roster and Hub-wide security audit without loading provisional product data. +- In real mode, signed-out visitors use the authentication shell. Signed-in members can browse the read-only repository index and open prototype details. Administrators can use the administrator roster and Hub-wide security audit without loading provisional product data. - In mock mode, the root reads the simulated session. A nested layout loads the private workspace for signed-in pages. - Route loaders and actions request product data and refresh it after changes. Shareable filters and selections live in the URL. - Product screens keep product-facing copy. API failures remain visible errors. diff --git a/hub/docs/development.md b/hub/docs/development.md index a4b71422..e03567ab 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -52,7 +52,7 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . React Router's Vite development server forwards `/api` requests to Express at . `GET /api/health` and `GET /api/ready` return JSON. The [authentication and Hub session guide](authentication.md) describes configured sign-in and enrollment. A signed-in member opens the first available repository, or an empty workspace when none exist. The real index supports search, user filtering, pagination, and latest Build status; prototype details are not connected yet. +Open . React Router's Vite development server forwards `/api` requests to Express at . `GET /api/health` and `GET /api/ready` return JSON. The [authentication and Hub session guide](authentication.md) describes configured sign-in and enrollment. A signed-in member opens the first available repository, or an empty workspace when none exist. The real index supports search, user filtering, pagination, and latest Build status. Prototype details are read-only; hosted previews, Build history, and live review remain unavailable. With `npm run dev:api`, the home page shows **Sign in as local developer**. Selecting it signs you in as one fixed local developer who is a Hub administrator. No password or outside account is involved. The API refuses to start in this mode unless `NODE_ENV` is `development` and `DATABASE_URL` points at a local development database. It also requires `HOST` to be `127.0.0.1`, `::1`, or `localhost` and `HUB_APPLICATION_ORIGIN` to be a loopback origin. It refuses GitHub or Entra settings at the same time. Unset origin, encryption key, and bootstrap secret values get local defaults. See [Local developer sign-in](authentication.md#local-developer-sign-in) for how this mode reuses the normal sign-in checks. The command defaults `NODE_ENV` to `development` and enables local sign-in for you. diff --git a/hub/docs/frontend.md b/hub/docs/frontend.md index b47ec37a..b61ba4d3 100644 --- a/hub/docs/frontend.md +++ b/hub/docs/frontend.md @@ -1,8 +1,8 @@ # Routed frontend -The Hub frontend uses React Router Framework Mode as a single-page application (SPA) rendered in the browser. In mock mode, it connects the retained pages to local product mocks. In real mode, the repository index, administrator roster, and Hub-wide security audit use their production APIs. Creation and branching open the original Copilot app handoff. +The Hub frontend uses React Router Framework Mode as a single-page application (SPA) rendered in the browser. In mock mode, it connects the retained pages to local product mocks. In real mode, the repository index, read-only prototype details, administrator roster, and Hub-wide security audit use their production APIs. Creation and branching open the original Copilot app handoff. -This frontend does not restore the production Hub. Configured GitHub authentication, Hub admission, browser sessions, and a read-only repository index are available; see [Authentication](authentication.md). The following remain separate backend work: +This frontend does not restore the production Hub. Configured GitHub authentication, Hub admission, browser sessions, and a read-only repository index and prototype detail API are available; see [Authentication](authentication.md). The following remain separate backend work: - Other product APIs and repository-specific permissions. - External product integrations. @@ -32,7 +32,7 @@ The dataset includes `octocat`, `mona`, additional fictional collaborators, `git In mock mode, the neutral root reads the simulated session. Private pages load their workspace in a separate layout; public shared previews and signed-out pages do not load a private workspace. These provisional product contracts are not adapters for real authentication or authorization. -In real mode, signed-out visitors see the authentication shell without loading provisional session or workspace data. Signed-in members open the first repository or an empty workspace. Administrators can open `/settings/administrators` and `/settings/audit?scope=hub` without loading provisional product workspace data. Sign-in, enrollment, and sign-out forms submit natively to the configured API. The index is read-only. Members can search it, filter it by user, and page through 24 prototypes at a time; its cards do not link to details yet. Other real product routes retain explicit API failures. In mock mode, `/` keeps the product entry flow, and `/admin-enrollment` redirects to the mock `/settings/enroll` flow. See [Hub development](development.md) for seed data and access limitations. +In real mode, signed-out visitors see the authentication shell without loading provisional session or workspace data. Signed-in members open the first repository or an empty workspace. Administrators can open `/settings/administrators` and `/settings/audit?scope=hub` without loading provisional product workspace data. Sign-in, enrollment, and sign-out forms submit natively to the configured API. Members can search the read-only index, filter it by user, page through 24 prototypes at a time, and open prototype details. The detail API selects the latest successful Build by default; `build` pins another successful Build, and invalid or unavailable selections return 404. The page offers only successful Builds until Build history and details are available. Sharing links are also unavailable. Authors from other providers are shown by name or login without a GitHub link. Build author metadata may be unknown for older records. Hosted previews and live review remain unavailable, and other real product routes retain explicit API failures. In mock mode, `/` keeps the product entry flow, and `/admin-enrollment` redirects to the mock `/settings/enroll` flow. See [Hub development](development.md) for seed data and access limitations. Product screens retain product-facing copy from the retained pages and their original fixtures. They do not describe mock data, simulations, or synthetic accounts. The bottom-right test toolbar is the only orange development surface and the only place for mock-only warnings, request diagnostics, and explanations of local actions. It can be collapsed on any viewport. Normal product validation, access-denied, and failure feedback remain in the product UI. @@ -189,7 +189,7 @@ These are frontend/mock contracts under [the application contracts](../client/ap | `GET /api/workspace` | Viewer and repository navigation | | `GET /api/repositories/:repositoryId` | Filtered, paginated prototypes and repository authors | | `GET /api/repositories/:repositoryId/authors` | Searchable user choices with a cursor | -| `GET /api/repositories/:repositoryId/prototypes/:prototypeId` | Prototype, build options, and selected preview | +| `GET /api/repositories/:repositoryId/prototypes/:prototypeId` | Prototype, Build records, and selected successful Build; real mode does not include hosted preview URLs | | `GET /api/repositories/:repositoryId/prototypes/:prototypeId/builds` | Paginated build history | | `GET /api/repositories/:repositoryId/prototypes/:prototypeId/builds/:buildNumber` | Build details | | `PATCH /api/repositories/:repositoryId/prototypes/:prototypeId` | Updated name/description and prototype data | @@ -203,7 +203,7 @@ These are frontend/mock contracts under [the application contracts](../client/ap | `GET /api/settings/workspace`, `/api/settings/public`, `/api/settings/admin-candidates` | Settings records and synthetic page capabilities | | `POST /api/settings/actions` | Explicitly named local actions; administrator role changes return 501 without changing state | -In real mode, Express implements `GET /api/workspace`, `GET /api/repositories/:repositoryId`, and its `authors` endpoint. The workspace lists the viewer and non-removed repositories. The repository index returns 24 eligible prototypes per page. It accepts `q`, `author`, and `page`, and rejects malformed values. Search ignores letter case and matches the prototype name, description, branch, and author login or name. A page past the end shows the last page. Each prototype includes its current Build or, when unset, the chronologically latest Build. The authors endpoint lists other users with eligible prototypes, 20 at a time, sorted by login. Its cursor only marks where the last page ended. The [authentication guide](authentication.md) owns the real session routes. +In real mode, Express implements `GET /api/workspace`, `GET /api/repositories/:repositoryId`, its `authors` endpoint, and `GET /api/repositories/:repositoryId/prototypes/:prototypeId`. The workspace lists the viewer and non-removed repositories. The repository index returns 24 eligible prototypes per page. It accepts `q`, `author`, and `page`, and rejects malformed values. Search ignores letter case and matches the prototype name, description, branch, and author login or name. A page past the end shows the last page. Each prototype includes its current Build or, when unset, the chronologically latest Build. The detail endpoint also includes unregistered prototypes and branches that were deleted, but excludes the default branch, replicas, and the reserved installation branch. It chooses the latest successful Build or a requested successful Build number; invalid and unavailable selections return 404. Unknown Build metadata remains null. Preview URLs and screenshots remain null because Hub does not host Builds yet. The authors endpoint lists other users with eligible prototypes, 20 at a time, sorted by login. Its cursor only marks where the last page ended. The [authentication guide](authentication.md) owns the real session routes. Real administrator management does not use the provisional settings contracts: @@ -245,7 +245,7 @@ npm run dev:api npm run dev ``` -Run those in separate terminals. `npm run dev:api` uses local developer sign-in; use `npm run dev:api:live` for configured GitHub or Entra sign-in. [Development](development.md#start-with-the-real-api) explains the choice. Real mode proxies `/api` to Express and never starts MSW or falls back to fixtures. Open `/` for sign-in, or `/admin-enrollment` for administrator enrollment. After restoring an administrator session, open `/settings/administrators` for administrator management or `/settings/audit?scope=hub` for Hub-wide security records. Express exposes health, readiness, authentication, administrator-management, and security-audit routes. It also exposes the seeded read-only workspace and repository index. Other product routes show explicit API failures until their real endpoints exist. +Run those in separate terminals. `npm run dev:api` uses local developer sign-in; use `npm run dev:api:live` for configured GitHub or Entra sign-in. [Development](development.md#start-with-the-real-api) explains the choice. Real mode proxies `/api` to Express and never starts MSW or falls back to fixtures. Open `/` for sign-in, or `/admin-enrollment` for administrator enrollment. After restoring an administrator session, open `/settings/administrators` for administrator management or `/settings/audit?scope=hub` for Hub-wide security records. Express exposes health, readiness, authentication, administrator-management, security-audit, workspace, repository-index, author-search, and prototype-detail routes. Prototype details are read-only and have no hosted preview yet. Other product routes show explicit API failures until their real endpoints exist. The [authentication guide](authentication.md) owns real sign-in, admission, and session behavior. Those routes and the backend's [telemetry](telemetry.md) and [WebSocket](websockets.md) foundations do not change the provisional mock product contracts. WebSocket upgrades are denied until application access checks are supplied. Mock sign-in and review actions remain local simulations. From f547b571722021ce1ac503b6136bb1df94fad2d9 Mon Sep 17 00:00:00 2001 From: Nishiki Liu Date: Thu, 12 Nov 2026 14:57:50 -0800 Subject: [PATCH 3/4] fix(hub): use real sign-out on prototype details Real prototype pages still used mock workspace navigation, so signing out did not end the Hub session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 335a01a0-881c-4159-bcf9-5f19b9b66bd4 --- .../application/hub/routes/real-prototype-index.test.tsx | 2 ++ .../application/hub/view-models/prototype-show-data/index.ts | 1 + .../application/hub/view-models/prototype-workspace/index.ts | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/hub/client/application/hub/routes/real-prototype-index.test.tsx b/hub/client/application/hub/routes/real-prototype-index.test.tsx index d6de02f6..f63a407c 100644 --- a/hub/client/application/hub/routes/real-prototype-index.test.tsx +++ b/hub/client/application/hub/routes/real-prototype-index.test.tsx @@ -388,6 +388,8 @@ describe("real prototype index", () => { ) expect(screen.queryByRole("button", { name: "More actions" })).not.toBeInTheDocument() + expect(document.querySelector('form[action="/api/auth/session/sign-out"]')).not.toBeNull() + expect(document.querySelector('form[action="/session"]')).toBeNull() fireEvent.click(screen.getByRole("button", { name: /Build 1.*v1/i })) const successful = screen.getByRole("menuitem", { name: /v3/i }).querySelector("a") diff --git a/hub/client/application/hub/view-models/prototype-show-data/index.ts b/hub/client/application/hub/view-models/prototype-show-data/index.ts index 99b66830..cbc5a4ec 100644 --- a/hub/client/application/hub/view-models/prototype-show-data/index.ts +++ b/hub/client/application/hub/view-models/prototype-show-data/index.ts @@ -23,6 +23,7 @@ export function prototypeShowData( selectedBuild, search, surface: "preview", + real: options.real, }) return { diff --git a/hub/client/application/hub/view-models/prototype-workspace/index.ts b/hub/client/application/hub/view-models/prototype-workspace/index.ts index d3ca8ded..5a7b6d16 100644 --- a/hub/client/application/hub/view-models/prototype-workspace/index.ts +++ b/hub/client/application/hub/view-models/prototype-workspace/index.ts @@ -17,6 +17,7 @@ export function prototypeWorkspace({ selectedBuild, search, surface, + real = false, }: { workspace: WorkspaceResponse repository: RepositoryRecord @@ -24,6 +25,7 @@ export function prototypeWorkspace({ selectedBuild: BuildRecord | null search: URLSearchParams surface: "preview" | "build" | "history" + real?: boolean }): PrototypeWorkspaceData { const path = `/repositories/${repository.id}/prototypes/${prototype.id}` const builds = [...prototype.builds].sort((first, second) => second.number - first.number) @@ -51,7 +53,7 @@ export function prototypeWorkspace({ }) return { - ...repositoryWorkspace(workspace, repository, search), + ...repositoryWorkspace(workspace, repository, search, real), author: { avatarUrl: author?.avatarUrl ?? null, label: authorName ? `By ${authorName}` : "Unknown author", From 7248493ab7e24b30c7a9d50bb3d6c2944d30e8ee Mon Sep 17 00:00:00 2001 From: Nishiki Liu Date: Thu, 12 Nov 2026 14:57:51 -0800 Subject: [PATCH 4/4] fix(hub): keep navigation without a preview The restore button only appears on a preview. Real Builds have no hosted preview yet, so a minimized link left members without navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 335a01a0-881c-4159-bcf9-5f19b9b66bd4 --- .../application/hub/routes/real-prototype-index.test.tsx | 8 +++++++- .../hub/view-models/prototype-show-data/index.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/hub/client/application/hub/routes/real-prototype-index.test.tsx b/hub/client/application/hub/routes/real-prototype-index.test.tsx index f63a407c..b01cc0df 100644 --- a/hub/client/application/hub/routes/real-prototype-index.test.tsx +++ b/hub/client/application/hub/routes/real-prototype-index.test.tsx @@ -403,9 +403,15 @@ describe("real prototype index", () => { await waitFor(() => expect(router.state.location.search).toBe("?build=3")) expect(await screen.findByText("Build 3 has no preview")).toBeVisible() await act(async () => - router.navigate(`/repositories/${repositoryId}/prototypes/${entraPrototypeId}`), + router.navigate( + `/repositories/${repositoryId}/prototypes/${entraPrototypeId}?view=minimized`, + ), ) expect((await screen.findByLabelText("By Mona")).closest("a")).toBeNull() + expect(document.querySelector(".review-workspace-layer")).toHaveAttribute( + "data-minimized", + "false", + ) await act(async () => router.navigate(`/repositories/${repositoryId}/prototypes/${fallbackPrototypeId}`), ) diff --git a/hub/client/application/hub/view-models/prototype-show-data/index.ts b/hub/client/application/hub/view-models/prototype-show-data/index.ts index cbc5a4ec..e1d05b84 100644 --- a/hub/client/application/hub/view-models/prototype-show-data/index.ts +++ b/hub/client/application/hub/view-models/prototype-show-data/index.ts @@ -75,7 +75,8 @@ export function prototypeShowData( minimizeHubLabel: "Minimize Hub UI", maximizeHubLabel: "Maximize Hub UI", dragControlsLabel: "Move preview controls. Drag or use arrow keys.", - minimizedPreview: search.get("view") === "minimized", + // The restore control lives on the preview, so stay expanded without one. + minimizedPreview: Boolean(previewBuild?.previewUrl) && search.get("view") === "minimized", pageTitle: `${data.prototype.name} · ${data.repository.nameWithOwner}`, prototypeId: data.prototype.id, repositoryId: data.repository.id,