1200 lines
33 KiB
TypeScript
1200 lines
33 KiB
TypeScript
import type { Page, Request, Route } from '@playwright/test'
|
|
|
|
export const PROPOSAL_ID = '11111111-1111-4111-8111-111111111111'
|
|
export const PROJECT_ID = '22222222-2222-4222-8222-222222222222'
|
|
export const DOC_PATH = 'nexus/mission-control.md'
|
|
export const MEMORY_NAME = '2026-07-31.md'
|
|
export const INCIDENT_NAME = '2026-07-30-gateway-timeout.md'
|
|
|
|
type NexusRole = 'owner' | 'member'
|
|
type ProposalStatus =
|
|
| 'awaiting_approval'
|
|
| 'provisioning'
|
|
| 'ready'
|
|
| 'partial'
|
|
| 'failed'
|
|
| 'in_doubt'
|
|
| 'rejected'
|
|
|
|
interface MockOptions {
|
|
authenticated?: boolean
|
|
role?: NexusRole
|
|
proposalStatus?: ProposalStatus
|
|
domainResyncSequence?: number
|
|
boardRefreshDelayMs?: number
|
|
boardRefreshTitle?: string
|
|
}
|
|
|
|
interface CapturedRequest {
|
|
path: string
|
|
method: string
|
|
headers: Record<string, string>
|
|
body: unknown
|
|
}
|
|
|
|
export interface NexusApiHarness {
|
|
readonly proposalRequests: CapturedRequest[]
|
|
readonly boardRequestUrls: string[]
|
|
readonly domainEventRequests: Array<{ lastEventId: string | null }>
|
|
readonly taskMutationRequests: CapturedRequest[]
|
|
readonly chatRequests: CapturedRequest[]
|
|
readonly resyncResponseCount: number
|
|
readonly openClawOverviewRequestCount: number
|
|
readonly proposal: Record<string, unknown>
|
|
}
|
|
|
|
const now = '2026-07-31T10:00:00.000Z'
|
|
|
|
function collection(items: unknown[] = []) {
|
|
return {
|
|
state: 'ready',
|
|
items,
|
|
nextCursor: null,
|
|
message: null,
|
|
recovery: null,
|
|
checkedAt: now,
|
|
}
|
|
}
|
|
|
|
function task(
|
|
id: string,
|
|
title: string,
|
|
state: string,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
id,
|
|
title,
|
|
detail: `${title} detail`,
|
|
source: 'bao',
|
|
state,
|
|
priority: 'Medium',
|
|
assignedTo: 'bao',
|
|
parentTaskId: null,
|
|
projectId: null,
|
|
dueDate: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
isAgentTask: false,
|
|
expectedFrom: null,
|
|
lastActivityMessage: 'Visible E2E activity',
|
|
lastActivityAt: now,
|
|
childTaskCount: 0,
|
|
openChildTaskCount: 0,
|
|
hasVisibleDelegation: false,
|
|
childTasks: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const openTask = task('task-open-1', 'E2E Backlog task', 'Backlog')
|
|
const progressTask = task('task-progress-1', 'E2E Active task', 'In progress')
|
|
const firstDoneTask = task('task-done-1', 'E2E Done page one', 'Done')
|
|
const secondDoneTask = task('task-done-2', 'E2E Done page two', 'Done')
|
|
const projectTask = task('task-project-1', 'E2E Project task', 'In progress', {
|
|
projectId: PROJECT_ID,
|
|
assignedTo: 'iris',
|
|
expectedFrom: 'iris',
|
|
isAgentTask: true,
|
|
})
|
|
const unrelatedTask = task('task-unrelated-1', 'E2E Unrelated task', 'Backlog', {
|
|
projectId: null,
|
|
})
|
|
|
|
const projectFixture = {
|
|
id: PROJECT_ID,
|
|
name: 'Release Readiness',
|
|
description: 'Coordinates the release across tasks, agents, and runs.',
|
|
status: 'Online',
|
|
progress: 64,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
const projectRunFixture = {
|
|
id: '33333333-3333-4333-8333-333333333333',
|
|
title: 'Release readiness verification',
|
|
prompt: 'Verify the linked project.',
|
|
agentId: 'iris',
|
|
sessionKey: 'agent:iris:project-release',
|
|
status: 'completed',
|
|
taskId: projectTask.id,
|
|
projectId: PROJECT_ID,
|
|
openClawRunId: 'openclaw-project-run-1',
|
|
retriedFromRunId: null,
|
|
correlationId: 'e2e-project-correlation',
|
|
actor: 'bao',
|
|
lastError: null,
|
|
lastGatewaySequence: 42,
|
|
sequenceGapDetected: false,
|
|
canStop: false,
|
|
canRetry: true,
|
|
canResume: false,
|
|
resumeCapabilityMessage: 'Completed runs cannot be resumed.',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
startedAt: now,
|
|
finishedAt: now,
|
|
}
|
|
|
|
const agentDetailFixture = {
|
|
id: 'iris',
|
|
name: 'Iris',
|
|
role: 'Chief of Staff',
|
|
model: 'openai/gpt-5.4',
|
|
status: 'Online',
|
|
lastSeen: now,
|
|
workspace: '/managed/agents/iris',
|
|
agentDir: '/managed/agents/iris/agent',
|
|
description: 'Coordinates E2E work.',
|
|
subAgents: ['release-sentinel'],
|
|
identityName: 'Iris',
|
|
}
|
|
|
|
const agentActivityFixture = [
|
|
{
|
|
id: 81,
|
|
type: 'task',
|
|
message: 'Verified the OpenClaw content inventory.',
|
|
at: now,
|
|
source: 'activity',
|
|
relativeTime: 'just now',
|
|
},
|
|
]
|
|
|
|
const agentSummaryFixture = {
|
|
now: {
|
|
text: 'Verified the OpenClaw content inventory.',
|
|
source: 'nexus-activity',
|
|
timestamp: now,
|
|
},
|
|
today: {
|
|
text: 'Last 24h: Verified the OpenClaw content inventory.',
|
|
source: 'nexus-activity',
|
|
timestamp: now,
|
|
},
|
|
generatedAt: now,
|
|
}
|
|
|
|
const standardAgentFiles = [
|
|
'AGENTS.md',
|
|
'SOUL.md',
|
|
'TOOLS.md',
|
|
'IDENTITY.md',
|
|
'USER.md',
|
|
'HEARTBEAT.md',
|
|
'BOOTSTRAP.md',
|
|
'MEMORY.md',
|
|
] as const
|
|
|
|
const agentFileCollectionFixture = {
|
|
agentId: 'iris',
|
|
files: standardAgentFiles.map((name, index) => ({
|
|
name,
|
|
missing: false,
|
|
size: 64 + index,
|
|
updatedAt: now,
|
|
contentHash: `sha256-e2e-${name.toLowerCase().replace('.', '-')}`,
|
|
})),
|
|
checkedAt: now,
|
|
}
|
|
|
|
const docFixture = {
|
|
name: 'mission-control.md',
|
|
path: DOC_PATH,
|
|
category: 'nexus',
|
|
type: 'md',
|
|
size: 112,
|
|
modifiedAt: now,
|
|
sourceAgentId: 'iris',
|
|
workspacePath: DOC_PATH,
|
|
}
|
|
|
|
const docDetailFixture = {
|
|
name: docFixture.name,
|
|
path: docFixture.path,
|
|
content: '# Mission Control Runbook\n\nOpenClaw-backed documentation is visible in Nexus.',
|
|
size: docFixture.size,
|
|
modifiedAt: docFixture.modifiedAt,
|
|
sourceAgentId: docFixture.sourceAgentId,
|
|
workspacePath: docFixture.workspacePath,
|
|
}
|
|
|
|
const memoryFixture = {
|
|
name: MEMORY_NAME,
|
|
path: MEMORY_NAME,
|
|
size: 96,
|
|
modifiedAt: now,
|
|
sourceAgentId: 'iris',
|
|
workspacePath: `memory/${MEMORY_NAME}`,
|
|
}
|
|
|
|
const memoryDetailFixture = {
|
|
...memoryFixture,
|
|
content: '# Daily Memory\n\nAgent-first context is loaded from Iris.',
|
|
}
|
|
|
|
const memorySearchFixture = {
|
|
name: memoryFixture.name,
|
|
path: memoryFixture.path,
|
|
excerpt: 'Agent-first context is loaded from Iris.',
|
|
size: memoryFixture.size,
|
|
sourceAgentId: memoryFixture.sourceAgentId,
|
|
workspacePath: memoryFixture.workspacePath,
|
|
}
|
|
|
|
const incidentFixture = {
|
|
name: INCIDENT_NAME,
|
|
title: 'Gateway Timeout Recovered',
|
|
date: '2026-07-30',
|
|
severity: 'major',
|
|
excerpt: 'The gateway recovered without duplicate provisioning.',
|
|
size: 144,
|
|
sourceAgentId: 'iris',
|
|
workspacePath: `memory/incidents/${INCIDENT_NAME}`,
|
|
}
|
|
|
|
const incidentDetailFixture = {
|
|
name: incidentFixture.name,
|
|
title: incidentFixture.title,
|
|
date: incidentFixture.date,
|
|
content: [
|
|
'# Gateway Timeout Recovered',
|
|
'',
|
|
'**Severity:** major',
|
|
'',
|
|
'The gateway recovered without duplicate provisioning.',
|
|
].join('\n'),
|
|
size: incidentFixture.size,
|
|
sourceAgentId: incidentFixture.sourceAgentId,
|
|
workspacePath: incidentFixture.workspacePath,
|
|
}
|
|
|
|
const securityStatusFixture = {
|
|
authMethod: 'JWT + PBKDF2',
|
|
tokenConfig: {
|
|
issuer: 'nexus',
|
|
audience: 'nexus-web',
|
|
refreshTokenDays: 7,
|
|
accessTokenMinutes: 30,
|
|
},
|
|
rateLimit: '5 login attempts per minute per IP',
|
|
passwordPolicy: 'Minimum 10 characters',
|
|
cookieConfig: {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'Strict',
|
|
},
|
|
twoFactorEnabled: false,
|
|
passkeyEnabled: false,
|
|
checkedAt: now,
|
|
}
|
|
|
|
function buildProposal(status: ProposalStatus = 'awaiting_approval'): Record<string, unknown> {
|
|
return {
|
|
id: PROPOSAL_ID,
|
|
source: 'manual',
|
|
requestedName: 'Release Sentinel',
|
|
requestedAgentId: 'release-sentinel',
|
|
role: 'Release Operations',
|
|
description: 'Verifies release readiness and reports blockers.',
|
|
model: 'openai/gpt-5.4',
|
|
emoji: null,
|
|
avatar: null,
|
|
workspace: '/managed/agents/release-sentinel',
|
|
files: [
|
|
{
|
|
name: 'SOUL.md',
|
|
contentHash: 'abc123def456abc123def456',
|
|
size: 42,
|
|
content: '# Release Sentinel\nVerify before reporting.',
|
|
},
|
|
],
|
|
status,
|
|
requestedBy: 'Bao Owner',
|
|
approvedBy: status === 'ready' ? 'Bao Owner' : null,
|
|
rejectedBy: null,
|
|
rejectionReason: null,
|
|
openClawAgentId: status === 'ready' ? 'release-sentinel' : null,
|
|
openClawWorkspace: status === 'ready' ? '/managed/agents/release-sentinel' : null,
|
|
error: null,
|
|
revision: status === 'ready' ? 2 : 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
approvedAt: status === 'ready' ? now : null,
|
|
rejectedAt: null,
|
|
completedAt: status === 'ready' ? now : null,
|
|
}
|
|
}
|
|
|
|
function openClawOverview() {
|
|
const agents = [
|
|
{
|
|
id: 'iris',
|
|
name: 'Iris',
|
|
description: 'Coordinates E2E work.',
|
|
model: 'openai/gpt-5.4',
|
|
provider: 'openai',
|
|
workspace: '/managed/agents/iris',
|
|
status: 'ready',
|
|
},
|
|
]
|
|
return {
|
|
connection: {
|
|
state: 'connected',
|
|
configured: true,
|
|
credentialConfigured: true,
|
|
connected: true,
|
|
endpoint: 'ws://openclaw-gateway:18789',
|
|
gatewayVersion: '2026.7.1',
|
|
requiredVersion: '2026.7.1',
|
|
versionPinned: true,
|
|
versionMatches: true,
|
|
protocolVersion: 4,
|
|
grantedScopes: ['operator.read', 'operator.admin'],
|
|
advertisedEvents: [],
|
|
lastConnectedAt: now,
|
|
lastEventAt: now,
|
|
reconnectAttempts: 0,
|
|
message: 'Connected',
|
|
recovery: null,
|
|
checkedAt: now,
|
|
deviceId: 'nexus-e2e',
|
|
pairingRequired: false,
|
|
pairingRequestId: null,
|
|
},
|
|
capabilities: [],
|
|
tasks: collection([]),
|
|
sessions: collection([]),
|
|
cronJobs: collection([]),
|
|
approvals: collection([]),
|
|
activity: collection([]),
|
|
models: collection([]),
|
|
agents: collection(agents),
|
|
generatedAt: now,
|
|
}
|
|
}
|
|
|
|
function operationsSnapshot() {
|
|
return {
|
|
generatedAt: now,
|
|
runtime: {
|
|
runtime: 'OpenClaw',
|
|
status: 'Connected',
|
|
detail: 'E2E fixture',
|
|
},
|
|
models: [],
|
|
metrics: {
|
|
activeAgents: 1,
|
|
queuedTasks: 2,
|
|
successRate: 100,
|
|
incidents: 0,
|
|
},
|
|
projects: [],
|
|
tasks: [],
|
|
activity: [],
|
|
}
|
|
}
|
|
|
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
function requestBody(request: Request): unknown {
|
|
const raw = request.postData()
|
|
if (!raw) return null
|
|
try {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function problem(detail: string, status: number) {
|
|
return {
|
|
type: 'about:blank',
|
|
title: status === 403 ? 'Forbidden' : 'Not found',
|
|
status,
|
|
detail,
|
|
}
|
|
}
|
|
|
|
function operationResult(
|
|
status: string,
|
|
primaryRef: { type: string; id: string; label?: string | null },
|
|
affectedRefs: Array<{ type: string; id: string; label?: string | null }> = [],
|
|
) {
|
|
return {
|
|
operationId: `e2e-${status}-${primaryRef.id}`,
|
|
status,
|
|
revision: 1,
|
|
primaryRef,
|
|
affectedRefs,
|
|
traceId: 'e2e-trace-1',
|
|
}
|
|
}
|
|
|
|
export async function mockNexusApi(
|
|
page: Page,
|
|
options: MockOptions = {},
|
|
): Promise<NexusApiHarness> {
|
|
const authenticated = options.authenticated ?? true
|
|
const role = options.role ?? 'owner'
|
|
const proposalRequests: CapturedRequest[] = []
|
|
const boardRequestUrls: string[] = []
|
|
const domainEventRequests: Array<{ lastEventId: string | null }> = []
|
|
const taskMutationRequests: CapturedRequest[] = []
|
|
const chatRequests: CapturedRequest[] = []
|
|
let proposal = buildProposal(options.proposalStatus)
|
|
let resyncResponseCount = 0
|
|
let boardInitialRequestCount = 0
|
|
let openClawOverviewRequestCount = 0
|
|
let openTaskState = 'Backlog'
|
|
|
|
await page.route('**/api/**', async route => {
|
|
const request = route.request()
|
|
const url = new URL(request.url())
|
|
const { pathname: path } = url
|
|
const method = request.method()
|
|
|
|
// The Vite source graph contains paths such as /src/api/client.ts, which
|
|
// also match Playwright's **/api/** glob. Only intercept the actual HTTP
|
|
// API boundary and let application modules continue to the dev server.
|
|
if (!path.startsWith('/api/')) {
|
|
await route.fallback()
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/auth/refresh' && method === 'POST') {
|
|
if (!authenticated) {
|
|
await fulfillJson(route, problem('No active E2E session.', 401), 401)
|
|
return
|
|
}
|
|
await fulfillJson(route, {
|
|
accessToken: `e2e-${role}-token`,
|
|
expiresAt: '2026-08-01T10:00:00.000Z',
|
|
user: {
|
|
id: role === 'owner' ? 'owner-e2e' : 'member-e2e',
|
|
email: role === 'owner' ? 'bao@example.test' : 'viewer@example.test',
|
|
displayName: role === 'owner' ? 'Bao Owner' : 'Nexus Viewer',
|
|
role,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/auth/logout' && method === 'POST') {
|
|
await route.fulfill({ status: 204 })
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/events') {
|
|
const lastEventId = request.headers()['last-event-id'] ?? null
|
|
domainEventRequests.push({ lastEventId })
|
|
|
|
if (options.domainResyncSequence && resyncResponseCount === 0) {
|
|
const deadline = Date.now() + 2_000
|
|
while (!boardRequestUrls.some(value => !value.includes('doneCursor=')) && Date.now() < deadline) {
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
// Let the initial query paint before the stream requests a REST resync.
|
|
await new Promise(resolve => setTimeout(resolve, 120))
|
|
resyncResponseCount += 1
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
headers: { 'Cache-Control': 'no-cache' },
|
|
body: [
|
|
`id: ${options.domainResyncSequence}`,
|
|
'event: resync_required',
|
|
`data: ${JSON.stringify({
|
|
sequence: options.domainResyncSequence,
|
|
eventType: 'resync_required',
|
|
entity: { type: 'event-stream', id: '*', label: null },
|
|
entityRevision: `stream-${options.domainResyncSequence}`,
|
|
occurredAt: now,
|
|
payload: { reason: 'stale_or_missing_cursor' },
|
|
})}`,
|
|
'',
|
|
'',
|
|
].join('\n'),
|
|
})
|
|
return
|
|
}
|
|
|
|
const cursorLine = options.domainResyncSequence
|
|
? `id: ${options.domainResyncSequence}\n`
|
|
: ''
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
headers: { 'Cache-Control': 'no-cache' },
|
|
body: `${cursorLine}event: heartbeat\ndata: {}\n\n`,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/projects' && method === 'GET') {
|
|
await fulfillJson(route, [projectFixture])
|
|
return
|
|
}
|
|
if (path === '/api/v1/activity' && method === 'GET') {
|
|
await fulfillJson(route, {
|
|
items: [{
|
|
id: 71,
|
|
type: 'task.updated',
|
|
message: 'Nexus task deep link is ready.',
|
|
at: now,
|
|
entity: {
|
|
type: 'task',
|
|
id: openTask.id,
|
|
label: openTask.title,
|
|
},
|
|
}],
|
|
totalCount: 1,
|
|
page: 1,
|
|
pageSize: 200,
|
|
totalPages: 1,
|
|
})
|
|
return
|
|
}
|
|
if (path === `/api/v1/projects/${PROJECT_ID}` && method === 'GET') {
|
|
await fulfillJson(route, projectFixture)
|
|
return
|
|
}
|
|
if (path === `/api/v1/projects/${PROJECT_ID}/tasks` && method === 'GET') {
|
|
await fulfillJson(route, [projectTask])
|
|
return
|
|
}
|
|
if (path === '/api/v1/tasks' && method === 'GET') {
|
|
await fulfillJson(route, [projectTask, unrelatedTask])
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/operations/snapshot') {
|
|
await fulfillJson(route, operationsSnapshot())
|
|
return
|
|
}
|
|
if (path === '/api/v1/routing') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
if (path === '/api/v1/openclaw/overview') {
|
|
openClawOverviewRequestCount += 1
|
|
await fulfillJson(route, openClawOverview())
|
|
return
|
|
}
|
|
if (path === '/api/v1/openclaw/capabilities') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
if (path === '/api/v1/openclaw/models/auth-status' && method === 'GET') {
|
|
await fulfillJson(route, collection([{
|
|
provider: 'openai',
|
|
displayName: 'OpenAI',
|
|
status: 'ok',
|
|
expiry: null,
|
|
profiles: [{ type: 'oauth', status: 'ok', count: 1 }],
|
|
apiKey: null,
|
|
usage: { summary: 'Ready', plan: 'team' },
|
|
}]))
|
|
return
|
|
}
|
|
if (path === '/api/v1/openclaw/agents' && method === 'GET') {
|
|
await fulfillJson(route, openClawOverview().agents)
|
|
return
|
|
}
|
|
if (path === '/api/v1/agents/iris' && method === 'GET') {
|
|
await fulfillJson(route, agentDetailFixture)
|
|
return
|
|
}
|
|
if (path === '/api/v1/agents/iris/activity' && method === 'GET') {
|
|
await fulfillJson(route, agentActivityFixture)
|
|
return
|
|
}
|
|
if (path === '/api/v1/agents/iris/summary' && method === 'GET') {
|
|
await fulfillJson(route, agentSummaryFixture)
|
|
return
|
|
}
|
|
if (
|
|
path === '/api/v1/openclaw/agents/iris/files'
|
|
&& method === 'GET'
|
|
) {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
await fulfillJson(route, agentFileCollectionFixture)
|
|
return
|
|
}
|
|
const agentFileMatch = path.match(
|
|
/^\/api\/v1\/openclaw\/agents\/iris\/files\/([^/]+)$/,
|
|
)
|
|
if (agentFileMatch && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
const fileName = decodeURIComponent(agentFileMatch[1])
|
|
const summary = agentFileCollectionFixture.files.find(
|
|
file => file.name === fileName,
|
|
)
|
|
if (!summary) {
|
|
await fulfillJson(route, problem('Agent file not found.', 404), 404)
|
|
return
|
|
}
|
|
await fulfillJson(route, {
|
|
agentId: 'iris',
|
|
...summary,
|
|
content: `# ${fileName}\n\nOpenClaw returned the authoritative Iris file.`,
|
|
checkedAt: now,
|
|
})
|
|
return
|
|
}
|
|
if (
|
|
path === '/api/v1/openclaw/agents/iris/workspace'
|
|
&& method === 'GET'
|
|
) {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
await fulfillJson(route, {
|
|
agentId: 'iris',
|
|
path: url.searchParams.get('path') ?? '',
|
|
parentPath: null,
|
|
entries: [],
|
|
totalEntries: 0,
|
|
offset: Number(url.searchParams.get('offset') ?? 0),
|
|
checkedAt: now,
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/v1/docs' && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
await fulfillJson(route, [docFixture])
|
|
return
|
|
}
|
|
if (path.startsWith('/api/v1/docs/') && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
const requestedPath = decodeURIComponent(
|
|
path.slice('/api/v1/docs/'.length),
|
|
)
|
|
await fulfillJson(
|
|
route,
|
|
requestedPath === DOC_PATH
|
|
? docDetailFixture
|
|
: problem('Document not found.', 404),
|
|
requestedPath === DOC_PATH ? 200 : 404,
|
|
)
|
|
return
|
|
}
|
|
if (path === '/api/v1/memory' && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
await fulfillJson(route, [memoryFixture])
|
|
return
|
|
}
|
|
if (path === '/api/v1/memory/search' && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
const query = url.searchParams.get('q')?.toLocaleLowerCase() ?? ''
|
|
await fulfillJson(
|
|
route,
|
|
query.includes('agent') ? [memorySearchFixture] : [],
|
|
)
|
|
return
|
|
}
|
|
const memoryMatch = path.match(/^\/api\/v1\/memory\/([^/]+)$/)
|
|
if (memoryMatch && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
const requestedName = decodeURIComponent(memoryMatch[1])
|
|
await fulfillJson(
|
|
route,
|
|
requestedName === MEMORY_NAME
|
|
? memoryDetailFixture
|
|
: problem('Memory file not found.', 404),
|
|
requestedName === MEMORY_NAME ? 200 : 404,
|
|
)
|
|
return
|
|
}
|
|
if (path === '/api/v1/incidents' && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
await fulfillJson(route, [incidentFixture])
|
|
return
|
|
}
|
|
const incidentMatch = path.match(/^\/api\/v1\/incidents\/([^/]+)$/)
|
|
if (incidentMatch && method === 'GET') {
|
|
if (role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
const requestedName = decodeURIComponent(incidentMatch[1])
|
|
await fulfillJson(
|
|
route,
|
|
requestedName === INCIDENT_NAME
|
|
? incidentDetailFixture
|
|
: problem('Incident not found.', 404),
|
|
requestedName === INCIDENT_NAME ? 200 : 404,
|
|
)
|
|
return
|
|
}
|
|
if (path === '/api/v1/security/status' && method === 'GET') {
|
|
await fulfillJson(route, securityStatusFixture)
|
|
return
|
|
}
|
|
if (path === '/api/v1/openclaw/runs' && method === 'GET') {
|
|
await fulfillJson(route, {
|
|
items: url.searchParams.get('projectId') === PROJECT_ID
|
|
? [projectRunFixture]
|
|
: [],
|
|
nextCursor: null,
|
|
checkedAt: now,
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/chat/messages' && method === 'GET') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
if (path === '/api/v1/chat' && method === 'POST') {
|
|
chatRequests.push({
|
|
path,
|
|
method,
|
|
headers: request.headers(),
|
|
body: requestBody(request),
|
|
})
|
|
await fulfillJson(route, {
|
|
runtime: 'OpenClaw Protocol v4',
|
|
agentId: 'iris',
|
|
conversationId: 'e2e-iris-conversation',
|
|
content: 'OpenClaw accepted the Iris request.',
|
|
runId: 'run-chat-e2e-1',
|
|
state: 'running',
|
|
operation: {
|
|
operationId: 'run-chat-e2e-1',
|
|
status: 'running',
|
|
revision: 1,
|
|
primaryRef: {
|
|
type: 'run',
|
|
id: 'run-chat-e2e-1',
|
|
label: 'Iris agent proposal run',
|
|
},
|
|
affectedRefs: [],
|
|
traceId: null,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/notifications/unread-count') {
|
|
await fulfillJson(route, { count: 0 })
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/notifications/snapshot') {
|
|
await fulfillJson(route, {
|
|
notifications: [{
|
|
id: '33333333-3333-4333-8333-333333333333',
|
|
type: 'task_assigned',
|
|
title: 'Inspect E2E backlog task',
|
|
message: 'A linked task requires attention.',
|
|
forUser: 'bao',
|
|
taskId: openTask.id,
|
|
isRead: false,
|
|
createdAt: now,
|
|
}],
|
|
unreadCount: 1,
|
|
forUser: 'bao',
|
|
})
|
|
return
|
|
}
|
|
if (
|
|
/^\/api\/dashboard\/notifications\/[^/]+\/read$/.test(path)
|
|
&& method === 'PATCH'
|
|
) {
|
|
const notificationId = path.split('/').at(-2)!
|
|
await fulfillJson(route, {
|
|
id: notificationId,
|
|
type: 'task_assigned',
|
|
title: 'Inspect E2E backlog task',
|
|
message: 'A linked task requires attention.',
|
|
forUser: 'bao',
|
|
taskId: openTask.id,
|
|
isRead: true,
|
|
createdAt: now,
|
|
operation: operationResult(
|
|
'completed',
|
|
{ type: 'notification', id: notificationId, label: 'Inspect E2E backlog task' },
|
|
[{ type: 'task', id: openTask.id, label: openTask.title }],
|
|
),
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/notifications/read-all' && method === 'PATCH') {
|
|
await fulfillJson(route, {
|
|
marked: 1,
|
|
operation: operationResult('completed', {
|
|
type: 'notification',
|
|
id: '*',
|
|
label: 'Benachrichtigungen',
|
|
}),
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/v1/telemetry/browser') {
|
|
await route.fulfill({ status: 202 })
|
|
return
|
|
}
|
|
|
|
if (path === '/api/dashboard/agents') {
|
|
await fulfillJson(route, [
|
|
{
|
|
id: 'iris',
|
|
name: 'Iris',
|
|
role: 'Chief of Staff',
|
|
description: 'Coordinates E2E work.',
|
|
tags: ['Orchestration'],
|
|
model: 'openai/gpt-5.4',
|
|
statusLabel: 'Bereit',
|
|
statusKind: 'ready',
|
|
statusDetail: 'Mock runtime is ready.',
|
|
isActive: false,
|
|
progress: null,
|
|
currentTask: null,
|
|
},
|
|
])
|
|
return
|
|
}
|
|
|
|
const isProposalRequest = path === '/api/v1/openclaw/agents/create-options'
|
|
|| path.startsWith('/api/v1/openclaw/agent-proposals')
|
|
if (isProposalRequest && role !== 'owner') {
|
|
await fulfillJson(route, problem('Owner access is required.', 403), 403)
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/openclaw/agents/create-options' && method === 'GET') {
|
|
await fulfillJson(route, {
|
|
canSubmitProposal: true,
|
|
canProvision: true,
|
|
state: 'ready',
|
|
reason: null,
|
|
workspaceRoot: '/managed/agents',
|
|
existingAgentIds: ['iris'],
|
|
models: [
|
|
{
|
|
id: 'openai/gpt-5.4',
|
|
name: 'GPT-5.4',
|
|
provider: 'OpenAI',
|
|
available: true,
|
|
},
|
|
{
|
|
id: 'unavailable/model',
|
|
name: 'Unavailable',
|
|
provider: 'Fixture',
|
|
available: false,
|
|
},
|
|
],
|
|
standardFiles: ['AGENTS.md', 'SOUL.md', 'TOOLS.md'],
|
|
checkedAt: now,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/openclaw/agent-proposals' && method === 'GET') {
|
|
await fulfillJson(route, {
|
|
items: [proposal],
|
|
nextCursor: null,
|
|
checkedAt: now,
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/openclaw/agent-proposals' && method === 'POST') {
|
|
const body = requestBody(request) as Record<string, unknown>
|
|
proposalRequests.push({
|
|
path,
|
|
method,
|
|
headers: request.headers(),
|
|
body,
|
|
})
|
|
const fileEntries = body.files && typeof body.files === 'object'
|
|
? Object.entries(body.files as Record<string, string>)
|
|
: []
|
|
const requestedName = String(body.name || 'Unnamed Agent')
|
|
proposal = {
|
|
...buildProposal('awaiting_approval'),
|
|
requestedName,
|
|
requestedAgentId: requestedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
|
|
role: body.role || null,
|
|
description: body.description || null,
|
|
model: body.model || null,
|
|
files: fileEntries.map(([name, content], index) => ({
|
|
name,
|
|
contentHash: `e2e-hash-${index}`,
|
|
size: content.length,
|
|
content,
|
|
})),
|
|
}
|
|
await fulfillJson(route, {
|
|
ok: true,
|
|
state: 'awaiting_approval',
|
|
message: 'Agent-Vorschlag wurde angelegt.',
|
|
proposal,
|
|
recovery: null,
|
|
correlationId: request.headers()['x-correlation-id'] ?? null,
|
|
completedAt: now,
|
|
}, 201)
|
|
return
|
|
}
|
|
|
|
if (path === `/api/v1/openclaw/agent-proposals/${PROPOSAL_ID}` && method === 'GET') {
|
|
await fulfillJson(route, proposal)
|
|
return
|
|
}
|
|
|
|
const actionMatch = path.match(
|
|
new RegExp(`^/api/v1/openclaw/agent-proposals/${PROPOSAL_ID}/(approve|reject|retry)$`),
|
|
)
|
|
if (actionMatch && method === 'POST') {
|
|
const action = actionMatch[1]
|
|
const body = requestBody(request)
|
|
proposalRequests.push({
|
|
path,
|
|
method,
|
|
headers: request.headers(),
|
|
body,
|
|
})
|
|
|
|
if (action === 'approve' || action === 'retry') {
|
|
proposal = {
|
|
...proposal,
|
|
status: 'ready',
|
|
approvedBy: 'Bao Owner',
|
|
approvedAt: now,
|
|
openClawAgentId: proposal.requestedAgentId,
|
|
openClawWorkspace: proposal.workspace,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
revision: Number(proposal.revision) + 1,
|
|
}
|
|
} else {
|
|
const actionBody = body as Record<string, unknown>
|
|
proposal = {
|
|
...proposal,
|
|
status: 'rejected',
|
|
rejectedBy: 'Bao Owner',
|
|
rejectionReason: actionBody.reason || 'Rejected in E2E',
|
|
rejectedAt: now,
|
|
completedAt: now,
|
|
updatedAt: now,
|
|
revision: Number(proposal.revision) + 1,
|
|
}
|
|
}
|
|
|
|
await fulfillJson(route, {
|
|
ok: true,
|
|
state: proposal.status,
|
|
message: action === 'reject'
|
|
? 'Agent-Vorschlag wurde abgelehnt.'
|
|
: 'Agent ist bereit.',
|
|
proposal,
|
|
recovery: null,
|
|
correlationId: request.headers()['x-correlation-id'] ?? null,
|
|
completedAt: now,
|
|
}, action === 'reject' ? 200 : 202)
|
|
return
|
|
}
|
|
|
|
if (path === '/api/v1/tasks/board' && method === 'GET') {
|
|
boardRequestUrls.push(url.toString())
|
|
const cursor = url.searchParams.get('doneCursor')
|
|
if (cursor) {
|
|
await fulfillJson(route, {
|
|
revision: 'board-r1',
|
|
offen: [],
|
|
inProgress: [],
|
|
review: [],
|
|
blocked: [],
|
|
done: [secondDoneTask],
|
|
nextDoneCursor: null,
|
|
hasMoreDone: false,
|
|
})
|
|
} else {
|
|
boardInitialRequestCount += 1
|
|
if (boardInitialRequestCount > 1 && options.boardRefreshDelayMs) {
|
|
await new Promise(resolve => setTimeout(resolve, options.boardRefreshDelayMs))
|
|
}
|
|
const currentOpenTask = {
|
|
...openTask,
|
|
title: boardInitialRequestCount > 1 && options.boardRefreshTitle
|
|
? options.boardRefreshTitle
|
|
: openTask.title,
|
|
state: openTaskState,
|
|
}
|
|
const columns = {
|
|
offen: openTaskState === 'Backlog' ? [currentOpenTask] : [],
|
|
inProgress: openTaskState === 'In progress'
|
|
? [currentOpenTask, progressTask]
|
|
: [progressTask],
|
|
review: openTaskState === 'Review' ? [currentOpenTask] : [],
|
|
blocked: openTaskState === 'Blocked' ? [currentOpenTask] : [],
|
|
done: openTaskState === 'Done'
|
|
? [currentOpenTask, firstDoneTask]
|
|
: [firstDoneTask],
|
|
}
|
|
await fulfillJson(route, {
|
|
revision: boardInitialRequestCount > 1 && options.boardRefreshTitle
|
|
? 'board-r2'
|
|
: 'board-r1',
|
|
...columns,
|
|
nextDoneCursor: 'done-page-2',
|
|
hasMoreDone: true,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (path === `/api/dashboard/tasks/${openTask.id}` && method === 'GET') {
|
|
await fulfillJson(route, {
|
|
...openTask,
|
|
state: openTaskState,
|
|
})
|
|
return
|
|
}
|
|
if (path === `/api/dashboard/tasks/${openTask.id}/children` && method === 'GET') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
if (path === `/api/dashboard/tasks/${openTask.id}/activity` && method === 'GET') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
|
|
const boardCardMatch = path.match(/^\/api\/v1\/tasks\/([^/]+)\/board-card$/)
|
|
if (boardCardMatch && method === 'GET') {
|
|
const taskId = decodeURIComponent(boardCardMatch[1])
|
|
if (taskId !== openTask.id) {
|
|
await fulfillJson(route, problem('Task not found.', 404), 404)
|
|
return
|
|
}
|
|
await fulfillJson(route, {
|
|
...openTask,
|
|
state: openTaskState,
|
|
updatedAt: now,
|
|
})
|
|
return
|
|
}
|
|
|
|
const moveTaskMatch = path.match(/^\/api\/dashboard\/tasks\/([^/]+)\/move$/)
|
|
if (moveTaskMatch && method === 'PATCH') {
|
|
const body = requestBody(request) as Record<string, unknown>
|
|
taskMutationRequests.push({
|
|
path,
|
|
method,
|
|
headers: request.headers(),
|
|
body,
|
|
})
|
|
const requestedState = String(body.state || 'Backlog')
|
|
const canonicalStates: Record<string, string> = {
|
|
offen: 'Backlog',
|
|
inProgress: 'In progress',
|
|
review: 'Review',
|
|
blocked: 'Blocked',
|
|
done: 'Done',
|
|
}
|
|
if (decodeURIComponent(moveTaskMatch[1]) === openTask.id) {
|
|
openTaskState = canonicalStates[requestedState] ?? requestedState
|
|
}
|
|
await fulfillJson(route, {
|
|
...openTask,
|
|
state: openTaskState,
|
|
updatedAt: now,
|
|
operation: operationResult('state_updated', {
|
|
type: 'task',
|
|
id: openTask.id,
|
|
label: openTask.title,
|
|
}),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/dashboard/tasks/agent-overview') {
|
|
await fulfillJson(route, {
|
|
waitingForBao: [],
|
|
waitingForIris: [],
|
|
waitingForOthers: [],
|
|
staleTasks: [],
|
|
staleThreshold: '02:00:00',
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/tasks') {
|
|
await fulfillJson(route, [openTask, progressTask, firstDoneTask])
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/tasks/board') {
|
|
await fulfillJson(route, {
|
|
offen: [openTask],
|
|
inProgress: [progressTask],
|
|
review: [],
|
|
blocked: [],
|
|
done: [firstDoneTask],
|
|
})
|
|
return
|
|
}
|
|
|
|
if (path === '/api/dashboard/status') {
|
|
await fulfillJson(route, {
|
|
gatewayOk: true,
|
|
irisStatus: 'Ready',
|
|
activeAgents: 1,
|
|
pendingTasks: 2,
|
|
})
|
|
return
|
|
}
|
|
if (path === '/api/dashboard/operations' || path === '/api/dashboard/queue') {
|
|
await fulfillJson(route, [])
|
|
return
|
|
}
|
|
|
|
await fulfillJson(route, problem(`No E2E fixture for ${method} ${path}.`, 404), 404)
|
|
})
|
|
|
|
return {
|
|
proposalRequests,
|
|
boardRequestUrls,
|
|
domainEventRequests,
|
|
taskMutationRequests,
|
|
chatRequests,
|
|
get resyncResponseCount() {
|
|
return resyncResponseCount
|
|
},
|
|
get openClawOverviewRequestCount() {
|
|
return openClawOverviewRequestCount
|
|
},
|
|
get proposal() {
|
|
return proposal
|
|
},
|
|
}
|
|
}
|