426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
271 lines
8.9 KiB
TypeScript
271 lines
8.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
/**
|
|
* GoalsView is the GUI data wrapper over the read-only goals endpoint
|
|
* served by the personal-assistant routes:
|
|
* GET {base}/api/lifeops/goals -> { goals: LifeOpsGoalRecord[] }
|
|
*
|
|
* It owns the fetch state machine (loading / error / ready), the status-filter
|
|
* selection, and the quiet background poll, then renders the unified
|
|
* {@link GoalsSpatialView} inside a SpatialSurface. These tests drive the
|
|
* rendered spatial DOM, asserting the populated grouped list, the status-filter
|
|
* toggle, the error -> Retry refetch, the empty set-a-goal chat affordance, and
|
|
* the quiet 20s poll. The fetcher seam is injected so the suite stays offline;
|
|
* `@elizaos/ui` is mocked so the wrapper renders outside a provider.
|
|
*
|
|
* External-API contract: the wire shape is mirrored verbatim from the PA
|
|
* `/api/lifeops/goals` response (LifeOpsGoalRecord = { goal, links } from
|
|
* @elizaos/shared); the fixtures below match that shape field-for-field.
|
|
*/
|
|
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// `@elizaos/ui` is the giant renderer barrel; GoalsView only touches
|
|
// `client.getBaseUrl()` (default fetcher seam, overridden in every test) and
|
|
// `client.sendChatMessage()` (set-a-goal affordance).
|
|
const { sendChatMessage } = vi.hoisted(() => ({ sendChatMessage: vi.fn() }));
|
|
vi.mock("@elizaos/ui", () => ({
|
|
client: {
|
|
getBaseUrl: () => "http://test.local",
|
|
sendChatMessage,
|
|
},
|
|
}));
|
|
|
|
import {
|
|
type GoalsFetchers,
|
|
GoalsView,
|
|
} from "../src/components/goals/GoalsView.tsx";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wire fixtures — mirror { goals: LifeOpsGoalRecord[] } exactly.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function goalRecord(
|
|
overrides: {
|
|
id?: string;
|
|
title?: string;
|
|
description?: string;
|
|
status?: string;
|
|
reviewState?: string;
|
|
cadenceKind?: string | null;
|
|
target?: string | null;
|
|
linkCount?: number;
|
|
} = {},
|
|
) {
|
|
const id = overrides.id ?? "goal-1";
|
|
const linkCount = overrides.linkCount ?? 0;
|
|
return {
|
|
goal: {
|
|
id,
|
|
agentId: "agent-1",
|
|
domain: "personal",
|
|
subjectType: "owner",
|
|
subjectId: "owner-1",
|
|
visibilityScope: "private",
|
|
contextPolicy: "owner_only",
|
|
title: overrides.title ?? "Run a half marathon",
|
|
description: overrides.description ?? "Build up to 21km by autumn.",
|
|
cadence:
|
|
overrides.cadenceKind === undefined
|
|
? { kind: "weekly" }
|
|
: overrides.cadenceKind === null
|
|
? null
|
|
: { kind: overrides.cadenceKind },
|
|
successCriteria:
|
|
overrides.target === undefined
|
|
? { targetText: "21km continuous run" }
|
|
: overrides.target === null
|
|
? {}
|
|
: { targetText: overrides.target },
|
|
status: overrides.status ?? "active",
|
|
reviewState: overrides.reviewState ?? "on_track",
|
|
metadata: {},
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-06-10T00:00:00.000Z",
|
|
},
|
|
links: Array.from({ length: linkCount }, (_, i) => ({
|
|
id: `link-${id}-${i}`,
|
|
agentId: "agent-1",
|
|
goalId: id,
|
|
linkedType: "occurrence",
|
|
linkedId: `occ-${i}`,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
})),
|
|
};
|
|
}
|
|
|
|
function makeFetchers(overrides: Partial<GoalsFetchers> = {}): GoalsFetchers {
|
|
return {
|
|
fetchGoals: async () => ({ goals: [goalRecord()] }),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function agent(agentId: string): HTMLElement {
|
|
const el = document.querySelector(`[data-agent-id="${agentId}"]`);
|
|
if (!el) throw new Error(`no element with data-agent-id="${agentId}"`);
|
|
return el as HTMLElement;
|
|
}
|
|
|
|
function queryAgent(agentId: string): HTMLElement | null {
|
|
return document.querySelector(
|
|
`[data-agent-id="${agentId}"]`,
|
|
) as HTMLElement | null;
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
sendChatMessage.mockClear();
|
|
});
|
|
|
|
describe("GoalsView — spatial GUI wrapper", () => {
|
|
it("shows the loading line while the first fetch is in flight", () => {
|
|
const never = new Promise<never>(() => {});
|
|
render(<GoalsView fetchers={makeFetchers({ fetchGoals: () => never })} />);
|
|
expect(screen.getByText("Loading goals")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the populated list grouped by status with real fields", async () => {
|
|
render(
|
|
<GoalsView
|
|
fetchers={makeFetchers({
|
|
fetchGoals: async () => ({
|
|
goals: [
|
|
goalRecord({
|
|
id: "g-active",
|
|
title: "Run a half marathon",
|
|
status: "active",
|
|
reviewState: "on_track",
|
|
cadenceKind: "weekly",
|
|
target: "21km continuous run",
|
|
linkCount: 2,
|
|
}),
|
|
goalRecord({
|
|
id: "g-paused",
|
|
title: "Learn Spanish",
|
|
status: "paused",
|
|
reviewState: "idle",
|
|
}),
|
|
],
|
|
}),
|
|
})}
|
|
/>,
|
|
);
|
|
await screen.findByText("Run a half marathon");
|
|
// Cadence + target + linked-count meta line.
|
|
expect(
|
|
screen.getByText(/weekly · 21km continuous run · 2 linked/),
|
|
).toBeTruthy();
|
|
expect(screen.getByText("Learn Spanish")).toBeTruthy();
|
|
// The status-filter chips are present and addressable by the agent surface.
|
|
expect(agent("filter:active")).toBeTruthy();
|
|
expect(agent("filter:paused")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the empty state when zero goals exist (no fabricated goals)", async () => {
|
|
render(
|
|
<GoalsView
|
|
fetchers={makeFetchers({ fetchGoals: async () => ({ goals: [] }) })}
|
|
/>,
|
|
);
|
|
// #9486 'declutter plugin app views' replaced the "No goals yet" empty-state
|
|
// copy with a bold "None" + the set-a-goal affordance; wait on the affordance
|
|
// (data-agent-id="new", unique to the empty state) instead of the removed text.
|
|
await waitFor(() => {
|
|
expect(queryAgent("new")).not.toBeNull();
|
|
});
|
|
expect(queryAgent("filter:active")).toBeNull();
|
|
});
|
|
|
|
it("routes the set-a-goal affordance through the assistant chat", async () => {
|
|
render(
|
|
<GoalsView
|
|
fetchers={makeFetchers({ fetchGoals: async () => ({ goals: [] }) })}
|
|
/>,
|
|
);
|
|
// #9486 'declutter plugin app views' replaced the "No goals yet" empty-state
|
|
// copy with a bold "None" + the set-a-goal affordance; wait on the affordance
|
|
// (data-agent-id="new", unique to the empty state) instead of the removed text.
|
|
await waitFor(() => {
|
|
expect(queryAgent("new")).not.toBeNull();
|
|
});
|
|
fireEvent.click(agent("new"));
|
|
expect(sendChatMessage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("shows the error state with a Retry that refetches into the populated list", async () => {
|
|
let attempt = 0;
|
|
const fetchGoals = async () => {
|
|
attempt += 1;
|
|
if (attempt === 1) throw new Error("boom");
|
|
return { goals: [goalRecord()] };
|
|
};
|
|
render(<GoalsView fetchers={makeFetchers({ fetchGoals })} />);
|
|
await screen.findByText("Could not load goals");
|
|
expect(screen.getByText("boom")).toBeTruthy();
|
|
fireEvent.click(agent("retry"));
|
|
await screen.findByText("Run a half marathon");
|
|
});
|
|
|
|
it("quietly refetches on the background poll (no manual refresh control)", async () => {
|
|
let calls = 0;
|
|
const fetchGoals = async () => {
|
|
calls += 1;
|
|
return { goals: [goalRecord({ title: `pass ${calls}` })] };
|
|
};
|
|
vi.useFakeTimers();
|
|
try {
|
|
render(<GoalsView fetchers={makeFetchers({ fetchGoals })} />);
|
|
await vi.waitFor(() => {
|
|
expect(screen.getByText("pass 1")).toBeTruthy();
|
|
});
|
|
expect(calls).toBe(1);
|
|
|
|
// One poll tick → exactly one more silent refetch, no loading flash.
|
|
await vi.advanceTimersByTimeAsync(20000);
|
|
expect(calls).toBe(2);
|
|
await vi.waitFor(() => {
|
|
expect(screen.getByText("pass 2")).toBeTruthy();
|
|
});
|
|
expect(screen.queryByText("Loading goals")).toBeNull();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("narrows the visible groups when a status filter chip is toggled", async () => {
|
|
render(
|
|
<GoalsView
|
|
fetchers={makeFetchers({
|
|
fetchGoals: async () => ({
|
|
goals: [
|
|
goalRecord({ id: "g-active", status: "active" }),
|
|
goalRecord({
|
|
id: "g-paused",
|
|
title: "Learn Spanish",
|
|
status: "paused",
|
|
}),
|
|
],
|
|
}),
|
|
})}
|
|
/>,
|
|
);
|
|
await screen.findByText("Run a half marathon");
|
|
expect(screen.getByText("Learn Spanish")).toBeTruthy();
|
|
|
|
// Toggle the "Paused" filter: only the paused group should remain.
|
|
fireEvent.click(agent("filter:paused"));
|
|
await waitFor(() =>
|
|
expect(screen.queryByText("Run a half marathon")).toBeNull(),
|
|
);
|
|
expect(screen.getByText("Learn Spanish")).toBeTruthy();
|
|
});
|
|
});
|