feat(delivery): the Application page + owner consoles (v2 phase A3-6)
The user-visible payoff of the whole delivery workstream. - (app)/app: four tabs — pipeline (runs + the eight steps + a live console), releases (with demo rollback), production (target summary, confirmed deploy, history), Git. The header answers "is it live": demo URL, live release, health state. - Live console: batches onto an animation frame, stops following the tail the moment the user scrolls away from the bottom, strips ANSI colour codes and carriage-return redraws. A console that yanks the view back is worse than none. - Production panel: the target is shown as the operator recognises their server plus has_secret/ثبت شده. No field anywhere could render a credential. - contracts: the v2 delivery types and seven new WsEventType members. - /admin/sandbox (owner): per-role container counts, running containers, per-org limits, image mapping, stop with the id typed back (audited). A provider without the capability reports an empty list rather than an error. - /admin/domains (owner): every route with its owning app, attach/detach (host typed back), idempotent re-sync. - /projects removed entirely, with a permanent redirect to /app. Tests: 502 API (+3), 78 web (+11); ruff, mypy, tsc and lint clean. Report: docs/plan/reports/agent-3-phase-6.md
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* §v2-A3.6 — the Application page.
|
||||
*
|
||||
* The subjects are the things this page could get *wrong* in a way that matters:
|
||||
*
|
||||
* * the live console must batch and must stop following the tail when the user
|
||||
* scrolls away — a console that yanks the view back is worse than no console;
|
||||
* * ANSI colour codes must be stripped — raw, a build log is unreadable noise;
|
||||
* * production is the one irreversible action, so the confirmation must be real
|
||||
* and the deploy button must be absent for anyone who is not an org admin;
|
||||
* * no secret is ever rendered. The API returns `has_secret`, not the secret, and
|
||||
* this file asserts the secret string is nowhere in the DOM.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
session: {
|
||||
ready: true,
|
||||
user: null as { id: string; email: string } | null,
|
||||
organizations: [] as Array<{ id: string; slug: string }>,
|
||||
activeOrganization: null as { id: string; slug: string } | null,
|
||||
login: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
setActiveOrganization: vi.fn(),
|
||||
},
|
||||
channels: [] as string[],
|
||||
listProjects: vi.fn(),
|
||||
getRuntime: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
listReleases: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
productionOverview: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
rollbackDemo: vi.fn(),
|
||||
deployToProduction: vi.fn(),
|
||||
rollbackProduction: vi.fn(),
|
||||
testProductionTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[]) => {
|
||||
mocks.channels = channels;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError, api: { listProjects: mocks.listProjects } };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-delivery", () => ({
|
||||
deliveryApi: {
|
||||
getRuntime: mocks.getRuntime,
|
||||
listRuns: mocks.listRuns,
|
||||
listReleases: mocks.listReleases,
|
||||
getRun: mocks.getRun,
|
||||
productionOverview: mocks.productionOverview,
|
||||
startRun: mocks.startRun,
|
||||
rollbackDemo: mocks.rollbackDemo,
|
||||
deployToProduction: mocks.deployToProduction,
|
||||
rollbackProduction: mocks.rollbackProduction,
|
||||
testProductionTarget: mocks.testProductionTarget,
|
||||
},
|
||||
domainsApi: {},
|
||||
}));
|
||||
|
||||
import ApplicationPage from "@/app/(app)/app/page";
|
||||
import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console";
|
||||
|
||||
const ORG = { id: "org-1", slug: "acme" };
|
||||
|
||||
const PROJECT = {
|
||||
id: "proj-1",
|
||||
organization_id: ORG.id,
|
||||
name: "اپلیکیشن نمونه",
|
||||
slug: "sample-app",
|
||||
description: "",
|
||||
default_branch: "dev",
|
||||
created_at: "2026-10-13T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const RUNTIME = {
|
||||
project_id: "proj-1",
|
||||
demo_host: "acme.msa.artacloud.ir",
|
||||
demo_url: "https://acme.msa.artacloud.ir",
|
||||
host_port: 8100,
|
||||
status: "live",
|
||||
current_release_id: "rel-1",
|
||||
updated_at: "2026-10-13T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const RELEASE = {
|
||||
id: "rel-1",
|
||||
project_id: "proj-1",
|
||||
run_id: "run-1",
|
||||
sha: "abc1234def5678",
|
||||
is_demo_live: true,
|
||||
created_at: "2026-10-13T10:00:00.000Z",
|
||||
};
|
||||
|
||||
function run(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "run-1",
|
||||
project_id: "proj-1",
|
||||
branch: "dev",
|
||||
sha: "abc1234def5678",
|
||||
trigger: "engine",
|
||||
status: "succeeded",
|
||||
error_summary: "",
|
||||
started_at: "2026-10-13T10:00:00.000Z",
|
||||
finished_at: "2026-10-13T10:01:00.000Z",
|
||||
created_at: "2026-10-13T10:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PRODUCTION = {
|
||||
target: {
|
||||
kind: "ssh" as const,
|
||||
host: "deploy.example.com",
|
||||
port: 22,
|
||||
username: "deployer",
|
||||
auth_type: "password" as const,
|
||||
remote_path: "/srv/app",
|
||||
has_secret: true,
|
||||
host_fingerprint: "SHA256:abc",
|
||||
last_tested_at: "2026-10-13T09:00:00.000Z",
|
||||
last_test_ok: true,
|
||||
last_test_message: "اتصال و مسیر مقصد تأیید شد.",
|
||||
},
|
||||
deploys: [],
|
||||
};
|
||||
|
||||
function bootstrap({ asOwner = true } = {}) {
|
||||
mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null;
|
||||
mocks.session.organizations = [ORG];
|
||||
mocks.session.activeOrganization = ORG;
|
||||
mocks.listProjects.mockResolvedValue([PROJECT]);
|
||||
mocks.getRuntime.mockResolvedValue(RUNTIME);
|
||||
mocks.listRuns.mockResolvedValue([run()]);
|
||||
mocks.listReleases.mockResolvedValue([RELEASE]);
|
||||
mocks.getRun.mockResolvedValue({
|
||||
...run(),
|
||||
steps: [
|
||||
"checkout",
|
||||
"setup",
|
||||
"build",
|
||||
"test",
|
||||
"package",
|
||||
"deploy_demo",
|
||||
"healthcheck",
|
||||
"route",
|
||||
].map((name, index) => ({
|
||||
id: `s-${index}`,
|
||||
index,
|
||||
name,
|
||||
status: "succeeded",
|
||||
exit_code: 0,
|
||||
log_tail: "",
|
||||
duration_ms: 10,
|
||||
})),
|
||||
});
|
||||
mocks.productionOverview.mockResolvedValue(PRODUCTION);
|
||||
mocks.startRun.mockResolvedValue(run());
|
||||
mocks.rollbackDemo.mockResolvedValue(RELEASE);
|
||||
mocks.deployToProduction.mockResolvedValue({
|
||||
id: "dep-1",
|
||||
project_id: "proj-1",
|
||||
kind: "ssh",
|
||||
host: "deploy.example.com",
|
||||
status: "succeeded",
|
||||
message: "استقرار در production انجام شد.",
|
||||
log_tail: "done",
|
||||
release_id: "rel-1",
|
||||
rolled_back_from: null,
|
||||
started_at: "2026-10-13T10:00:00.000Z",
|
||||
finished_at: "2026-10-13T10:01:00.000Z",
|
||||
created_at: "2026-10-13T10:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
bootstrap();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the console helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
describe("the pipeline console", () => {
|
||||
it("strips ANSI colour codes and carriage-return redraws", () => {
|
||||
const raw = "[1;32mok[0m [31mfail[0m\r[2Kdone";
|
||||
expect(stripAnsi(raw)).toBe("ok fail\ndone");
|
||||
});
|
||||
|
||||
it("leaves a clean line untouched", () => {
|
||||
expect(stripAnsi("npm WARN deprecated foo@1.0.0")).toBe("npm WARN deprecated foo@1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
describe("the Application page", () => {
|
||||
it("renders the app, the demo URL and the live release", async () => {
|
||||
render(<ApplicationPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
expect(screen.getByText("https://acme.msa.artacloud.ir")).toBeInTheDocument();
|
||||
// The live release is shown as a short sha, never the raw one — in the
|
||||
// header and again next to the run that produced it.
|
||||
expect(screen.getAllByText("abc1234").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("abc1234def5678")).not.toBeInTheDocument();
|
||||
expect(mocks.channels).toEqual(["org:org-1"]);
|
||||
});
|
||||
|
||||
it("subscribes to the tenant channel so a build stays live", async () => {
|
||||
render(<ApplicationPage />);
|
||||
await waitFor(() => expect(mocks.channels).toEqual(["org:org-1"]));
|
||||
});
|
||||
|
||||
it("shows the eight pipeline steps once a run is opened", async () => {
|
||||
const { container } = render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
|
||||
// Opening a run fetches its detail; `getRun` is already stubbed in
|
||||
// `bootstrap()` with all eight steps. The run row is identified by the badge
|
||||
// the list renders for it — the first `<button>` on the page is the header's
|
||||
// "re-run", which does something else entirely.
|
||||
// `abc1234` appears in the header stat *and* in the run row; only the run
|
||||
// row is clickable, so pick that one.
|
||||
const runRow = within(container)
|
||||
.getAllByText("abc1234")
|
||||
.map((node) => node.closest("button"))
|
||||
.find((candidate) => candidate !== null);
|
||||
expect(runRow).not.toBeUndefined();
|
||||
runRow?.click();
|
||||
await waitFor(() => expect(screen.getByText("deploy_demo")).toBeInTheDocument());
|
||||
// ...and all eight of them, not just some.
|
||||
for (const name of ["checkout", "setup", "build", "test", "package", "healthcheck", "route"]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("never renders a credential", async () => {
|
||||
const { container } = render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
// The production section only renders when its tab is opened.
|
||||
const productionTab = screen.getByRole("tab", { name: "production" });
|
||||
productionTab.click();
|
||||
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
|
||||
|
||||
// The target's host and username are shown on purpose — they are how the
|
||||
// operator recognises their own server. What must never appear is the
|
||||
// credential, which the API does not even send.
|
||||
// The host is rendered with its port, so match the whole cell.
|
||||
expect(screen.getByText("deploy.example.com:22")).toBeInTheDocument();
|
||||
expect(screen.getByText("ثبت شده")).toBeInTheDocument();
|
||||
expect(container.textContent).not.toContain("deployer-password-value");
|
||||
const text = (container.textContent ?? "").toLowerCase();
|
||||
for (const key of ["secret_encrypted", "ssh_key", "auth_token", "bearer"]) {
|
||||
expect(text).not.toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the deploy button from a user without a session owner", async () => {
|
||||
bootstrap({ asOwner: false });
|
||||
render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
|
||||
expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// production: the confirmation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
describe("production deploy confirmation", () => {
|
||||
async function openProduction() {
|
||||
render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
screen.getByRole("tab", { name: "production" }).click();
|
||||
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
|
||||
}
|
||||
|
||||
it("shows the deploy button to an org admin", async () => {
|
||||
await openProduction();
|
||||
expect(screen.getByText("استقرار در production")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires the org slug to be typed before it will ask the server", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
await openProduction();
|
||||
|
||||
screen.getByText("استقرار در production").click();
|
||||
const field = await screen.findByPlaceholderText("acme");
|
||||
expect(field).toBeInTheDocument();
|
||||
// Nothing is sent until the operator confirms — the dialog is open, and that
|
||||
// in itself must not have started anything.
|
||||
expect(mocks.deployToProduction).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(field, { target: { value: "not-the-slug" } });
|
||||
screen.getByText("تأیید").click();
|
||||
|
||||
// The client sends what was typed. The *server* is what refuses a slug that
|
||||
// does not match — that is asserted in tests/test_deploy.py, because a
|
||||
// client-side gate is a hint, not a control.
|
||||
await waitFor(() =>
|
||||
expect(mocks.deployToProduction).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"proj-1",
|
||||
"not-the-slug",
|
||||
"rel-1",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the confirm dialog is a real one: nothing is dismissed by a stray click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
describe("the production panel as a whole", () => {
|
||||
it("reports a missing target instead of offering a deploy button", async () => {
|
||||
mocks.productionOverview.mockResolvedValue({ target: null, deploys: [] });
|
||||
render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
screen.getByRole("tab", { name: "production" }).click();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("مقصدی تنظیم نشده است")).toBeInTheDocument());
|
||||
expect(screen.queryByText("استقرار در production")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a failed deploy with its reason", async () => {
|
||||
mocks.productionOverview.mockResolvedValue({
|
||||
target: PRODUCTION.target,
|
||||
deploys: [
|
||||
{
|
||||
...({} as Record<string, unknown>),
|
||||
id: "dep-1",
|
||||
project_id: "proj-1",
|
||||
kind: "ssh",
|
||||
host: "deploy.example.com",
|
||||
status: "failed",
|
||||
message: "اجرای دستور پس از استقرار ناموفق بود.",
|
||||
log_tail: "",
|
||||
release_id: "rel-1",
|
||||
rolled_back_from: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: "2026-10-13T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<ApplicationPage />);
|
||||
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
|
||||
screen.getByRole("tab", { name: "production" }).click();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("اجرای دستور پس از استقرار ناموفق بود.")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user