Files
PaperClipAI/ui/src/pages/Auth.test.tsx
T
DottaandPaperclip 6f204605ad fix(ui): show source SHA for unreleased builds (#9508)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies
> - Operators need to identify the exact build running from the
persistent account menu
> - Formal releases already have a concise public version, but source
builds include a long derived version string
> - The derived version identifies a commit but does not expose the
source branch or a direct path to inspect the code
> - Server Git metadata is auth-sensitive, so the UI must also refresh
it when the current session changes
> - This pull request shows linked branch and commit metadata for source
builds while preserving `v<version>` for formal releases
> - The benefit is faster build diagnosis with correct metadata across
sign-in and sign-out transitions

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] I searched existing open and closed issues and found no duplicate
implementing this exact account-menu behavior.
- [x] The behavior reproduces on `master`.
- [x] The behavior originates in Paperclip's core UI, not an adapter,
provider, or local configuration.

### What happened?

Source builds displayed the full derived version, such as
`2026.626.0+58.git.518fc71ce`, without linking the operator to the
corresponding source branch or commit.

### Expected behavior

Source builds should show the concise branch and short commit SHA with
links to GitHub, while formal releases should continue showing their
public version. Auth transitions should refresh the health metadata that
supplies those Git details.

### Steps to reproduce

1. Run Paperclip from a commit after a release tag.
2. Open the account menu.
3. Inspect the build label beneath the user identity.
4. Sign in or out and reopen the menu.

### Paperclip version or commit

Any source build whose server version uses the
`<version>+<count>.git.<sha>[.dirty]` format.

### Deployment mode

Local dev (`pnpm dev`) or authenticated deployments.

### Installation method

Built from source.

## What Changed

- Detect source-derived version strings and render the source branch
plus seven-character commit SHA in `SidebarAccountMenu`.
- Link source branches and commits to the canonical
`paperclipai/paperclip` GitHub repository.
- Extend server Git metadata with the full SHA and expose it through
health/OpenAPI contracts.
- Refresh auth-sensitive health metadata after sign-in and every
sign-out entry point.
- Preserve the existing `v<version>` label for formal releases and add
focused regression coverage.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run src/pages/Auth.test.tsx
src/components/SidebarAccountMenu.test.tsx
src/components/SidebarServerInfo.test.tsx`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/health.test.ts src/__tests__/server-info.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `git diff --check public/master...HEAD`

## Risks

- Low risk: formal release rendering retains the existing fallback
behavior when the source-version pattern does not match.
- Source links assume the build came from the canonical public
repository; fork-only branches or commits may not resolve there.
- Health metadata is invalidated after auth transitions, adding one
bounded refetch so the displayed Git details match the new session.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.4 with medium reasoning, repository/tool
access, shell execution, and code editing; context-window size was not
exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-13 20:55:54 -05:00

257 lines
8.5 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { AuthPage } from "./Auth";
const getSessionMock = vi.hoisted(() => vi.fn());
const signInEmailMock = vi.hoisted(() => vi.fn());
const signUpEmailMock = vi.hoisted(() => vi.fn());
vi.mock("../api/auth", () => ({
authApi: {
getSession: () => getSessionMock(),
signInEmail: (input: unknown) => signInEmailMock(input),
signUpEmail: (input: unknown) => signUpEmailMock(input),
},
}));
// The ASCII art animation drives a canvas/requestAnimationFrame loop that adds
// nothing to these assertions, so stub it out.
vi.mock("@/components/AsciiArtAnimation", () => ({
AsciiArtAnimation: () => null,
}));
// The auth page renders a ThemeToggle, which reads ThemeContext. The provider
// lives in main.tsx (above the router), so mock the hook here the same way
// SidebarAccountMenu.test.tsx does.
vi.mock("../context/ThemeContext", () => ({
useTheme: () => ({
theme: "dark",
setTheme: vi.fn(),
toggleTheme: vi.fn(),
}),
}));
// The router's navigate wrapper reads the active company prefix from context.
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: null,
selectedCompanyId: null,
companies: [],
selectionSource: "manual",
loading: false,
error: null,
setSelectedCompanyId: vi.fn(),
reloadCompanies: vi.fn(),
createCompany: vi.fn(),
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
flushSync(() => {});
}
function renderAuthPage(container: HTMLElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return { root, queryClient };
}
describe("AuthPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
getSessionMock.mockResolvedValue(null);
signInEmailMock.mockResolvedValue(undefined);
signUpEmailMock.mockResolvedValue(undefined);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function mount() {
const { root, queryClient } = renderAuthPage(container);
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/auth"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/auth" element={<AuthPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
return { root, queryClient };
}
it("exposes password-manager metadata and a11y attributes on the sign-in form", async () => {
const { root } = await mount();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
// 1Password / password-manager recognition: identifier field is "username".
expect(emailInput.getAttribute("autocomplete")).toBe("username");
expect(emailInput.getAttribute("type")).toBe("email");
expect(passwordInput.getAttribute("autocomplete")).toBe("current-password");
// Stable ids/names for both inputs.
expect(emailInput.id).toBe("email");
expect(passwordInput.id).toBe("password");
// Required + programmatic required state.
expect(emailInput.required).toBe(true);
expect(emailInput.getAttribute("aria-required")).toBe("true");
expect(passwordInput.required).toBe(true);
expect(passwordInput.getAttribute("aria-required")).toBe("true");
// Programmatic labels.
expect(container.querySelector('label[for="email"]')).not.toBeNull();
expect(container.querySelector('label[for="password"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("uses new-password autocomplete in sign-up mode", async () => {
const { root } = await mount();
const createOne = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Create one",
);
expect(createOne).not.toBeNull();
await act(async () => {
createOne?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
expect(nameInput).not.toBeNull();
expect(nameInput.getAttribute("autocomplete")).toBe("name");
expect(nameInput.required).toBe(true);
expect(passwordInput.getAttribute("autocomplete")).toBe("new-password");
await act(async () => {
root.unmount();
});
});
it("renders auth errors in an assertive alert region referenced by the inputs", async () => {
const { root } = await mount();
const inputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "wrongpass");
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
});
signInEmailMock.mockRejectedValueOnce(new Error("Invalid email or password"));
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
const alert = container.querySelector('[role="alert"]') as HTMLElement;
expect(alert).not.toBeNull();
expect(alert.hasAttribute("aria-live")).toBe(false);
expect(alert.textContent).toContain("Invalid email or password");
const errorId = alert.id;
expect(errorId.length).toBeGreaterThan(0);
expect(emailInput.getAttribute("aria-describedby")).toBe(errorId);
expect(emailInput.getAttribute("aria-invalid")).toBe("true");
expect(passwordInput.getAttribute("aria-describedby")).toBe(errorId);
expect(passwordInput.getAttribute("aria-invalid")).toBe("true");
await act(async () => {
root.unmount();
});
});
it("invalidates anonymous health metadata after sign-in", async () => {
const { root, queryClient } = await mount();
queryClient.setQueryData(queryKeys.health, {
status: "ok",
deploymentMode: "authenticated",
});
const inputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement;
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
});
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "supersecret",
});
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
await act(async () => {
root.unmount();
});
});
});