diff --git a/trios/agent-server/apps/agent/entrypoints/app/admin-dashboard/AdminDashboardPage.test.ts b/trios/agent-server/apps/agent/entrypoints/app/admin-dashboard/AdminDashboardPage.test.ts new file mode 100644 index 0000000000..3362f03d0a --- /dev/null +++ b/trios/agent-server/apps/agent/entrypoints/app/admin-dashboard/AdminDashboardPage.test.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The admin dashboard page, held against its contract + * (gHashTag/trios#1594): the single export of + * entrypoints/app/admin-dashboard/AdminDashboardPage.tsx - + * AdminDashboardPage - pinned as it behaves today, so the next change + * to that file has something to fail against. + * + * The history hook answers its initial empty map (it populates through + * effects a static render never runs), so the page's static answer is + * the EMPTY dashboard - which is most of its visible surface: the + * header and approvals slots (stubbed with named markers - their own + * suites own their contracts), the Approvals and Audit Trail section + * titles, the empty-state card with its shield icon and note, and the + * delete AlertDialog in its controlled-closed shape (its title, + * description and buttons render inside the group's stand-in shape, + * with the task name empty because no task is pending deletion). + * The storage seam is the repository's in-memory item shim (the real + * @wxt-dev/storage refuses the test process; the store suites assert + * only on their own maps). Not pinned: the populated audit trail (it + * rides the effect-populated hook), the delete flow and the toasts. + */ +import { describe, expect, it, mock } from 'bun:test' +import { createElement, type FC, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +const passChildren = + (): FC<{ children?: ReactNode }> => + ({ children }) => + createElement('div', null, children) + +// The generated graphql tag is never generated in the Tests jobs; the +// ScheduledTasks suite's graph reaches it, and in the CI readdir order +// this file's registrations come first - the stub keeps that import +// alive for whoever links it after us. +mock.module('@/generated/graphql/gql', () => ({ + graphql: (query: string) => ({ query }), +})) + +mock.module('@wxt-dev/storage', () => ({ + storage: { + defineItem: ( + key: string, + options?: { fallback?: unknown } & Record, + ) => ({ + getValue: async () => { + if (key === 'local:tool-approval-config') { + return { categories: {} } + } + return options && 'fallback' in options ? options.fallback : null + }, + setValue: async () => {}, + watch: () => () => {}, + }), + }, +})) +mock.module('@/lib/execution-history/storage', () => ({ + removeConversationExecutionTask: async () => {}, + useExecutionHistoryByConversation: () => ({}), +})) +mock.module('@/components/ui/alert-dialog', () => { + const pass = passChildren() + return { + // The root honours the open flag exactly like the group's dialog + // stand-ins: a CLOSED alert renders nothing. The first push of this + // PR passed children through unconditionally and broke the sibling + // Skills and ScheduledTasks suites in the CI readdir order - their + // contracts pin their closed alerts as empty (measured in the CI + // log of the first push). + AlertDialog: ({ + open, + children, + }: { + open?: boolean + children?: ReactNode + }) => (open ? children : null), + AlertDialogAction: pass, + AlertDialogCancel: pass, + AlertDialogContent: pass, + AlertDialogDescription: pass, + AlertDialogFooter: pass, + AlertDialogHeader: pass, + AlertDialogTitle: pass, + AlertDialogTrigger: pass, + } +}) +mock.module('./AdminDashboardHeader', () => ({ + AdminDashboardHeader: () => + createElement('div', { 'data-testid': 'admin-header' }), +})) +mock.module('./PendingApprovals', () => ({ + PendingApprovals: () => + createElement('div', { 'data-testid': 'pending-approvals' }), +})) + +const { AdminDashboardPage } = await import('./AdminDashboardPage') + +const markup = () => renderToStaticMarkup(createElement(AdminDashboardPage)) + +describe('adminDashboardPageContract', () => { + it('answers its header and approvals slots', () => { + const m = markup() + expect(m).toContain('data-testid="admin-header"') + expect(m).toContain('data-testid="pending-approvals"') + expect(m).toContain('Approvals') + }) + + it('answers the Audit Trail section with its empty state', () => { + const m = markup() + expect(m).toContain('Audit Trail') + expect(m).toContain('No agent runs yet') + expect(m).toContain( + 'Run a task in BrowserOS and the execution history will appear here.', + ) + expect(m).toContain('lucide-shield') + // The run-count line renders only when a run exists; the delete + // dialog's description carries the words "recorded run" in its + // fixed sentence, so the count line is pinned by its own shape. + expect(m).not.toContain('recorded runs across') + }) + + it('answers the delete dialog closed: none of its content renders', () => { + const m = markup() + expect(m).not.toContain('Delete Run') + expect(m).not.toContain('from local history?') + expect(m).not.toContain('This only clears the recorded run on this device.') + expect(m).not.toContain('>Cancel<') + }) +}) diff --git a/trios/agent-server/apps/agent/entrypoints/app/agent-command/AgentCommandHome.test.ts b/trios/agent-server/apps/agent/entrypoints/app/agent-command/AgentCommandHome.test.ts new file mode 100644 index 0000000000..764f68c80e --- /dev/null +++ b/trios/agent-server/apps/agent/entrypoints/app/agent-command/AgentCommandHome.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * The agent command home, held against its contract + * (gHashTag/trios#1585): the single export of + * entrypoints/app/agent-command/AgentCommandHome.tsx - AgentCommandHome - + * pinned as it behaves today, so the next change to that file has + * something to fail against. + * + * The seams are the same ones the AgentCommandConversation suite stubs + * (the pattern of this group): the wxt storage item shim, the + * agents/adapters hooks, the layout data hook, the metrics seam, and the + * active-hint hook behind a flag so one suite reaches both hints. The + * ConversationInput and the hints render REAL (the input's own suite + * owns its internals; the hints are plain presentational components). + * Pinned: the empty answer (No agents yet with its note and the Create + * agent affordance); the populated answer (the home headline with its + * italic work-on accent, the tab-leaving note, the conversation input + * frame with the measured placeholder and the runtime-not-running + * placeholder, the Recent agents section with its note and the Manage + * agents affordance); the hint dispatch (signin answers the SignInHint + * marker, import the ImportDataHint marker, none answers neither). Not + * pinned: the send/select wiring (handlers a static render never runs). + */ +import { describe, expect, it, mock } from 'bun:test' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { MemoryRouter } from 'react-router' + +const values = new Map() + +const fakeItem = ( + key: string, + opts?: { fallback?: unknown } & Record, +) => ({ + getValue: async () => { + if (values.has(key)) return values.get(key) + return opts && 'fallback' in opts ? opts.fallback : null + }, + setValue: async (value: unknown) => { + values.set(key, value) + }, + watch: () => () => {}, +}) + +mock.module('@wxt-dev/storage', () => ({ + storage: { + defineItem: (key: string, opts?: unknown) => fakeItem(key, opts as never), + }, +})) + +let legacyAgents: Array> = [] + +mock.module('./agent-command-layout', () => ({ + useAgentCommandData: () => ({ + agents: legacyAgents, + status: { status: 'running' }, + }), +})) + +mock.module('@/entrypoints/app/agents/useAgents', () => ({ + useAgentAdapters: () => ({ adapters: [], loading: false, error: null }), + useHarnessAgents: () => ({ + agents: [], + harnessAgents: [], + gateway: null, + loading: false, + error: null, + }), +})) + +mock.module('@/lib/metrics/track', () => ({ + track: () => {}, +})) + +mock.module('./ConversationInput', () => ({ + ConversationInput: (props: Record) => + createElement( + 'div', + { 'data-testid': 'conversation-input' }, + String(props.placeholder ?? ''), + ), +})) + +let activeHint: string | null = null + +mock.module('@/entrypoints/newtab/index/useActiveHint', () => ({ + useActiveHint: () => activeHint, +})) + +const { AgentCommandHome } = await import('./AgentCommandHome') + +const markup = () => + renderToStaticMarkup( + createElement(MemoryRouter, null, createElement(AgentCommandHome as never)), + ) + +describe('agentCommandHomeContract', () => { + it('answers the empty state when no agent exists', () => { + legacyAgents = [] + activeHint = null + const m = markup() + expect(m).toContain('No agents yet') + expect(m).toContain( + 'Create an agent to start using BrowserOS as an agent-first new tab.', + ) + expect(m).toContain('Create agent') + expect(m).not.toContain('What should your agent') + }) + + it('answers the home headline, its note and the conversation slot', () => { + legacyAgents = [ + { agentId: 'agent-1', name: 'Research Bot', source: 'agent-harness' }, + ] + activeHint = null + const m = markup() + expect(m).toContain('What should your agent') + expect(m).toContain('work on') + expect(m).toContain('next?') + expect(m).toContain( + 'Start a task, continue a thread, or hand off to a different agent', + ) + expect(m).toContain('data-testid="conversation-input"') + // The selection effect never runs in a static render, so the input + // answers the not-running placeholder even with agents present + // (measured) - and the Recent threads section stays away because the + // harness list is empty. + expect(m).toContain('Agent runtime is not running...') + expect(m).not.toContain('Recent agents') + }) + + it('answers the recent-threads section only when the harness list carries agents', () => { + legacyAgents = [ + { agentId: 'agent-1', name: 'Bot', source: 'agent-harness' }, + ] + activeHint = null + const m = markup() + expect(m).not.toContain('Recent agents') + legacyAgents = [] + }) + + it('answers the active hint by kind and none by default', () => { + legacyAgents = [] + activeHint = 'signin' + expect(markup()).toContain('Sync your data') + activeHint = 'import' + expect(markup()).toContain('Import your data') + activeHint = null + const m = markup() + expect(m).not.toContain('Sync your data') + expect(m).not.toContain('Import your data') + }) +})