Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion apps/loopover-miner-ui/src/governor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
fetchGovernorPauseState,
Expand All @@ -11,6 +11,7 @@ import {
type GovernorPauseState,
type GovernorPauseStateResult,
} from "./lib/governor";
import { resetDemoDataForTest } from "./lib/demo-data";
import { GovernorControlSection } from "./routes/ledgers";
import {
governorApiPlugin,
Expand Down Expand Up @@ -99,6 +100,11 @@ describe("GovernorControlSection (#4857)", () => {
});

describe("fetchGovernorPauseState / pauseGovernor / resumeGovernor (#4857)", () => {
afterEach(() => {
vi.unstubAllEnvs();
resetDemoDataForTest();
});

const jsonResponse = (status: number, payload: unknown) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;

Expand Down Expand Up @@ -175,6 +181,40 @@ describe("fetchGovernorPauseState / pauseGovernor / resumeGovernor (#4857)", ()
}),
).toEqual(failing);
});

describe("demo mode (#5963)", () => {
it("fetchGovernorPauseState returns the canned (not-paused) demo state without ever calling fetch", async () => {
vi.stubEnv("VITE_DEMO_MODE", "1");
let called = false;
const result = await fetchGovernorPauseState(async () => {
called = true;
return jsonResponse(200, { pauseState: pausedState });
});
expect(called).toBe(false);
expect(result).toEqual({ ok: true, pauseState: { paused: false, reason: null, pausedAt: null } });
});

it("pauseGovernor/resumeGovernor mutate an in-memory demo state and round-trip through fetchGovernorPauseState, without ever calling fetch", async () => {
vi.stubEnv("VITE_DEMO_MODE", "1");
let called = false;
const failIfCalled = async () => {
called = true;
return jsonResponse(200, {});
};

const paused = await pauseGovernor("demo pause", failIfCalled);
expect(paused.ok).toBe(true);
expect(paused.ok && paused.pauseState.paused).toBe(true);
expect(paused.ok && paused.pauseState.reason).toBe("demo pause");
const afterPause = await fetchGovernorPauseState(failIfCalled);
expect(afterPause.ok && afterPause.pauseState.paused).toBe(true);

const resumed = await resumeGovernor(failIfCalled);
expect(resumed).toEqual({ ok: true, pauseState: { paused: false, reason: null, pausedAt: null } });

expect(called).toBe(false);
});
});
});

describe("matchGovernorRoute (#4857)", () => {
Expand Down
15 changes: 15 additions & 0 deletions apps/loopover-miner-ui/src/ledgers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ describe("LedgersPage (#4855)", () => {
});

describe("fetchLedgers (#4855)", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

const jsonResponse = (status: number, payload: unknown) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;

Expand Down Expand Up @@ -434,6 +438,17 @@ describe("fetchLedgers (#4855)", () => {
}),
).toEqual({ ok: false, error: "connection refused" });
});

it("#5963: in demo mode, returns a canned summary without ever calling fetch", async () => {
vi.stubEnv("VITE_DEMO_MODE", "1");
let called = false;
const result = await fetchLedgers(async () => {
called = true;
return jsonResponse(200, { summary: emptyLedgersSummary() });
});
expect(called).toBe(false);
expect(result.ok).toBe(true);
});
});

describe("handleLedgersRequest (#4855)", () => {
Expand Down
95 changes: 95 additions & 0 deletions apps/loopover-miner-ui/src/lib/demo-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEMO_LEDGERS_SUMMARY,
DEMO_PORTFOLIO_QUEUE_SUMMARY,
DEMO_RUN_STATES,
getDemoGovernorState,
getDemoPortfolioQueueItems,
isDemoMode,
removeDemoPortfolioQueueItem,
resetDemoDataForTest,
setDemoGovernorPaused,
setDemoGovernorResumed,
} from "./demo-data";

afterEach(() => {
vi.unstubAllEnvs();
resetDemoDataForTest();
});

describe("isDemoMode() (#5963)", () => {
it("is false when VITE_DEMO_MODE is unset", () => {
vi.stubEnv("VITE_DEMO_MODE", "");
expect(isDemoMode()).toBe(false);
});

it("is true only for the exact string '1'", () => {
vi.stubEnv("VITE_DEMO_MODE", "1");
expect(isDemoMode()).toBe(true);
vi.stubEnv("VITE_DEMO_MODE", "true");
expect(isDemoMode()).toBe(false);
});
});

describe("demo fixtures shape (#5963)", () => {
it("DEMO_RUN_STATES is non-empty and every row has a valid state", () => {
expect(DEMO_RUN_STATES.length).toBeGreaterThan(0);
for (const row of DEMO_RUN_STATES) {
expect(["idle", "discovering", "planning", "preparing"]).toContain(row.state);
}
});

it("DEMO_LEDGERS_SUMMARY's claim byStatus counts sum to its total", () => {
const { total, byStatus } = DEMO_LEDGERS_SUMMARY.claims;
expect(byStatus.active + byStatus.released + byStatus.expired).toBe(total);
});

it("DEMO_PORTFOLIO_QUEUE_SUMMARY's per-repo totals sum to the fleet total", () => {
const repoSum = DEMO_PORTFOLIO_QUEUE_SUMMARY.repos.reduce((sum, r) => sum + r.total, 0);
expect(repoSum).toBe(DEMO_PORTFOLIO_QUEUE_SUMMARY.total);
});
});

describe("demo governor pause state (#5963)", () => {
it("starts resumed (not paused)", () => {
expect(getDemoGovernorState()).toEqual({ paused: false, reason: null, pausedAt: null });
});

it("setDemoGovernorPaused sets paused=true with the given reason and a fresh timestamp", () => {
const state = setDemoGovernorPaused("investigating");
expect(state.paused).toBe(true);
expect(state.reason).toBe("investigating");
expect(state.pausedAt).toEqual(expect.any(String));
expect(getDemoGovernorState()).toEqual(state);
});

it("setDemoGovernorPaused accepts a null reason", () => {
expect(setDemoGovernorPaused(null).reason).toBeNull();
});

it("setDemoGovernorResumed clears paused/reason/pausedAt", () => {
setDemoGovernorPaused("x");
expect(setDemoGovernorResumed()).toEqual({ paused: false, reason: null, pausedAt: null });
});
});

describe("demo portfolio-queue items (#5963)", () => {
it("starts with the default fixture items", () => {
expect(getDemoPortfolioQueueItems().length).toBeGreaterThan(0);
});

it("removeDemoPortfolioQueueItem removes and returns the matching item", () => {
const beforeCount = getDemoPortfolioQueueItems().length;
const target = { ...getDemoPortfolioQueueItems()[0]! };
const removed = removeDemoPortfolioQueueItem(target.repoFullName, target.identifier);
expect(removed).toEqual(target);
expect(getDemoPortfolioQueueItems()).toHaveLength(beforeCount - 1);
expect(getDemoPortfolioQueueItems().find((i) => i.identifier === target.identifier)).toBeUndefined();
});

it("removeDemoPortfolioQueueItem returns null for an unknown item, without mutating the list", () => {
const before = getDemoPortfolioQueueItems().length;
expect(removeDemoPortfolioQueueItem("nope/nope", "does-not-exist")).toBeNull();
expect(getDemoPortfolioQueueItems()).toHaveLength(before);
});
});
152 changes: 152 additions & 0 deletions apps/loopover-miner-ui/src/lib/demo-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Demo mode (#5963): a build-time-flagged, zero-backend mock data layer so this dashboard can be deployed as a
// static demo with no real miner harness, local ledger files, or operator credentials behind it -- the same
// mechanism family as loopover-ui's signInPreview() escape hatch (apps/loopover-ui/src/lib/api/session.ts), just
// covering N fabricated API responses instead of one fake session object. `import.meta.env.VITE_DEMO_MODE` is a
// build-time constant, so the "off" branch (the real fetch calls) is dead-code-eliminated from a demo bundle and
// vice versa -- a production self-host build never carries this module's data.
//
// Scope: the five REST fetchers backing the three main dashboard routes (run-history, ledgers, portfolio +
// its queue actions, governor). discover/attempt/chat are NOT covered here -- those trigger a real coding-agent
// iteration or ground against a live MCP connection, and fabricating a convincing multi-minute agent run is a
// separate, much larger content-design task than tabular summary data (tracked as follow-up work, not this PR).
//
// Every value below is entirely synthetic -- no real repo, run, ledger entry, or account referenced anywhere.

import type { RunStateRow } from "./run-history";
import type { LedgersSummary } from "./ledgers";
import type { PortfolioQueueSummary } from "./portfolio-queue";
import type { PortfolioQueueActionItem } from "./portfolio-queue-actions";
import type { GovernorPauseState } from "./governor";

export function isDemoMode(): boolean {
return import.meta.env.VITE_DEMO_MODE === "1";
}

export const DEMO_RUN_STATES: RunStateRow[] = [
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "acme/widgets",
state: "preparing",
updatedAt: "2026-07-18T14:02:00.000Z",
},
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "acme/api-gateway",
state: "discovering",
updatedAt: "2026-07-18T13:47:00.000Z",
},
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "acme/docs-site",
state: "idle",
updatedAt: "2026-07-18T11:15:00.000Z",
},
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "northwind/inventory",
state: "planning",
updatedAt: "2026-07-18T12:30:00.000Z",
},
];

export const DEMO_LEDGERS_SUMMARY: LedgersSummary = {
claims: { total: 18, byStatus: { active: 3, released: 12, expired: 3 } },
events: {
total: 142,
byType: { claimed: 41, released: 38, event_recorded: 63 },
recent: [
{ eventType: "claimed", repoFullName: "acme/widgets", createdAt: "2026-07-18T14:00:00.000Z" },
{ eventType: "released", repoFullName: "acme/api-gateway", createdAt: "2026-07-18T13:45:00.000Z" },
{ eventType: "event_recorded", repoFullName: "acme/docs-site", createdAt: "2026-07-18T11:10:00.000Z" },
{ eventType: "claimed", repoFullName: "northwind/inventory", createdAt: "2026-07-18T09:30:00.000Z" },
{ eventType: "released", repoFullName: "acme/widgets", createdAt: "2026-07-17T22:14:00.000Z" },
],
},
governor: { total: 9, byEventType: { paused: 4, resumed: 5 } },
};

export const DEMO_PORTFOLIO_QUEUE_SUMMARY: PortfolioQueueSummary = {
total: 27,
byStatus: { queued: 9, in_progress: 3, done: 15 },
repos: [
{ repoFullName: "acme/widgets", byStatus: { queued: 4, in_progress: 1, done: 6 }, total: 11 },
{ repoFullName: "acme/api-gateway", byStatus: { queued: 2, in_progress: 1, done: 4 }, total: 7 },
{ repoFullName: "acme/docs-site", byStatus: { queued: 1, in_progress: 0, done: 3 }, total: 4 },
{ repoFullName: "northwind/inventory", byStatus: { queued: 2, in_progress: 1, done: 2 }, total: 5 },
],
oldestQueuedAgeMs: 6 * 60 * 60 * 1000, // 6h
};

const DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS: PortfolioQueueActionItem[] = [
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "acme/widgets",
identifier: "wgt-2451",
status: "in_progress",
},
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "acme/api-gateway",
identifier: "gw-118",
status: "in_progress",
},
{ apiBaseUrl: "https://forge.example.com", repoFullName: "acme/widgets", identifier: "wgt-2438", status: "done" },
{
apiBaseUrl: "https://forge.example.com",
repoFullName: "northwind/inventory",
identifier: "inv-77",
status: "done",
},
];

// Mutable, in-memory, browser-session-only copy -- release/requeue removes the item from this actionable list
// (simulating it going back to "queued", which this endpoint doesn't itself track), same session-only-state
// reasoning as the governor pause state below: a demo control that visibly does nothing is a worse demo than
// one that responds, and there's no real queue here to protect from a fabricated write.
let demoPortfolioQueueItems: PortfolioQueueActionItem[] = [...DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS];

export function getDemoPortfolioQueueItems(): PortfolioQueueActionItem[] {
return demoPortfolioQueueItems;
}

/** Remove one item (by repoFullName + identifier) from the demo actionable list, simulating a release/requeue.
* Returns the removed item, or null if no matching item was found (mirrors the real API's not-found shape). */
export function removeDemoPortfolioQueueItem(
repoFullName: string,
identifier: string,
): PortfolioQueueActionItem | null {
const index = demoPortfolioQueueItems.findIndex(
(item) => item.repoFullName === repoFullName && item.identifier === identifier,
);
if (index === -1) return null;
const [removed] = demoPortfolioQueueItems.splice(index, 1);
// A valid index always has exactly one element to splice out; the fallback only guards the array-access
// type, not a real runtime path.
return removed ?? null;
}

/** Test-only: restores the module-level mutable demo state (governor pause state + queue items) to its
* defaults, so one test's release/pause doesn't leak into the next. Never called from app code. */
export function resetDemoDataForTest(): void {
demoPortfolioQueueItems = [...DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS];
demoGovernorState = { paused: false, reason: null, pausedAt: null };
}

// Mutable, in-memory, browser-session-only -- pause/resume is harmless to actually simulate (no real governor,
// nothing to protect), and a static read-only demo of a control that visibly does nothing is a worse demo than
// one that responds. Resets to this default on every page reload; never persisted anywhere.
let demoGovernorState: GovernorPauseState = { paused: false, reason: null, pausedAt: null };

export function getDemoGovernorState(): GovernorPauseState {
return demoGovernorState;
}

export function setDemoGovernorPaused(reason: string | null): GovernorPauseState {
demoGovernorState = { paused: true, reason, pausedAt: new Date().toISOString() };
return demoGovernorState;
}

export function setDemoGovernorResumed(): GovernorPauseState {
demoGovernorState = { paused: false, reason: null, pausedAt: null };
return demoGovernorState;
}
5 changes: 5 additions & 0 deletions apps/loopover-miner-ui/src/lib/governor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// response, a guard narrowing the parsed JSON payload) but adds two WRITE actions, the miner-ui's first — safe
// only because vite-auth.ts (#4858) now authenticates every /api/* request, including these.

import { getDemoGovernorState, isDemoMode, setDemoGovernorPaused, setDemoGovernorResumed } from "./demo-data";

export const GOVERNOR_PAUSE_STATE_API_PATH = "/api/governor/pause-state";
export const GOVERNOR_PAUSE_API_PATH = "/api/governor/pause";
export const GOVERNOR_RESUME_API_PATH = "/api/governor/resume";
Expand Down Expand Up @@ -36,6 +38,7 @@ async function parseGovernorPauseStateResponse(

/** Fetch the governor's current pause state; failures surface as a typed error result the view renders, never a crash. */
export async function fetchGovernorPauseState(fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
if (isDemoMode()) return { ok: true, pauseState: getDemoGovernorState() };
try {
const response = await fetchImpl(GOVERNOR_PAUSE_STATE_API_PATH);
return await parseGovernorPauseStateResponse(response, "local governor pause-state API");
Expand Down Expand Up @@ -69,10 +72,12 @@ async function postGovernorAction(

/** Pause the governor, optionally with a reason (mirrors `loopover-miner governor pause [--reason <text>]`). */
export function pauseGovernor(reason?: string, fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
if (isDemoMode()) return Promise.resolve({ ok: true, pauseState: setDemoGovernorPaused(reason ?? null) });
return postGovernorAction(GOVERNOR_PAUSE_API_PATH, reason ? { reason } : {}, fetchImpl);
}

/** Resume the governor (mirrors `loopover-miner governor resume`). */
export function resumeGovernor(fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
if (isDemoMode()) return Promise.resolve({ ok: true, pauseState: setDemoGovernorResumed() });
return postGovernorAction(GOVERNOR_RESUME_API_PATH, {}, fetchImpl);
}
3 changes: 3 additions & 0 deletions apps/loopover-miner-ui/src/lib/ledgers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// enforce). This client just fetches that summary and validates its shape; a failure surfaces as a typed error
// result the view renders, never a crash.

import { DEMO_LEDGERS_SUMMARY, isDemoMode } from "./demo-data";

export const LEDGERS_API_PATH = "/api/ledgers";

export const CLAIM_STATUSES = ["active", "released", "expired"] as const;
Expand Down Expand Up @@ -56,6 +58,7 @@ function isLedgersSummary(value: unknown): value is LedgersSummary {

/** Fetch the local ledgers summary; failures surface as a typed error result the view renders, never a crash. */
export async function fetchLedgers(fetchImpl: typeof fetch = fetch): Promise<LedgersResult> {
if (isDemoMode()) return { ok: true, summary: DEMO_LEDGERS_SUMMARY };
try {
const response = await fetchImpl(LEDGERS_API_PATH);
if (!response.ok) return { ok: false, error: `local ledgers API responded ${response.status}` };
Expand Down
Loading
Loading