cd8c78d165
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { expect, test } from '@playwright/test'
|
|
import {
|
|
DOC_PATH,
|
|
INCIDENT_NAME,
|
|
MEMORY_NAME,
|
|
mockNexusApi,
|
|
PROJECT_ID,
|
|
PROPOSAL_ID,
|
|
} from './support/nexusApi'
|
|
|
|
const coreRoutes = [
|
|
{ path: '/dashboard', heading: 'Live-Orchestrierung' },
|
|
{ path: '/agents', heading: 'Agents' },
|
|
{ path: '/agents/iris', heading: 'Iris' },
|
|
{ path: '/agents/new', heading: 'Agent vorschlagen' },
|
|
{ path: `/agents/proposals/${PROPOSAL_ID}`, heading: 'Release Sentinel' },
|
|
{ path: '/projects', heading: 'Projects' },
|
|
{ path: `/projects/${PROJECT_ID}`, heading: 'Release Readiness' },
|
|
{ path: '/tasks', heading: 'Aufgaben' },
|
|
{ path: '/tasks/task-open-1', heading: 'E2E Backlog task' },
|
|
{ path: '/runs', heading: 'Run Control' },
|
|
{ path: '/runs/run-e2e-1', heading: 'Run detail' },
|
|
{ path: '/calendar', heading: 'Calendar' },
|
|
{ path: '/memory', heading: 'Memory' },
|
|
{ path: '/docs', heading: 'Docs' },
|
|
{ path: '/incidents', heading: 'Incidents' },
|
|
{ path: '/models', heading: 'Models' },
|
|
{ path: '/activity', heading: 'Activity' },
|
|
{ path: '/notifications', heading: 'Benachrichtigungen' },
|
|
{ path: '/security', heading: 'Security Center' },
|
|
{ path: '/settings', heading: 'Einstellungen' },
|
|
] as const
|
|
|
|
test.describe('authenticated route and deep-link smoke', () => {
|
|
test('redirects an unauthenticated deep link to login and preserves the target', async ({ page }) => {
|
|
await mockNexusApi(page, { authenticated: false })
|
|
|
|
await page.goto(`/agents/proposals/${PROPOSAL_ID}`)
|
|
|
|
await expect(page).toHaveURL(/\/login\?redirect=/)
|
|
const loginUrl = new URL(page.url())
|
|
expect(loginUrl.pathname).toBe('/login')
|
|
expect(loginUrl.searchParams.get('redirect')).toBe(`/agents/proposals/${PROPOSAL_ID}`)
|
|
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible()
|
|
})
|
|
|
|
test('does not present the rolling login window as a lock while attempts remain', async ({ page }) => {
|
|
await mockNexusApi(page, { authenticated: false })
|
|
await page.route('**/api/v1/auth/login', async route => {
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/problem+json',
|
|
headers: {
|
|
'X-RateLimit-Remaining': '4',
|
|
'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + 60),
|
|
},
|
|
body: JSON.stringify({
|
|
message: 'Invalid email or password.',
|
|
remaining: 4,
|
|
retryAfterSeconds: 60,
|
|
}),
|
|
})
|
|
})
|
|
|
|
await page.goto('/login')
|
|
await page.getByLabel('E-Mail').fill('release-smoke@example.invalid')
|
|
await page.getByLabel('Passwort', { exact: true }).fill('not-a-real-password')
|
|
await page.getByRole('button', { name: 'Anmelden' }).click()
|
|
|
|
await expect(page.getByRole('alert')).toContainText('4 Versuche verbleibend')
|
|
await expect(page.getByText(/Entsperrt in/)).toHaveCount(0)
|
|
await expect(page.getByRole('button', { name: 'Anmelden' })).toBeEnabled()
|
|
await expect(page.getByRole('button', { name: /Gesperrt/ })).toHaveCount(0)
|
|
})
|
|
|
|
test('renders the core owner deep links without a client-side exception', async ({ page }) => {
|
|
test.setTimeout(90_000)
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
const pageErrors: string[] = []
|
|
page.on('pageerror', error => pageErrors.push(error.message))
|
|
|
|
for (const route of coreRoutes) {
|
|
await page.goto(route.path)
|
|
await expect(page).toHaveURL(new RegExp(`${route.path.replaceAll('/', '\\/')}$`))
|
|
await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
|
|
}
|
|
|
|
expect(pageErrors).toEqual([])
|
|
})
|
|
|
|
test('links the Projects index to a directly addressable project result', async ({ page }) => {
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
|
|
await page.goto('/projects')
|
|
await expect(page.getByRole('heading', { name: 'Projects', exact: true })).toBeVisible()
|
|
const projectLink = page.getByRole('link', { name: /Release Readiness/ })
|
|
await expect(projectLink).toHaveAttribute('href', `/projects/${PROJECT_ID}`)
|
|
await projectLink.click()
|
|
|
|
await expect(page).toHaveURL(`/projects/${PROJECT_ID}`)
|
|
await expect(page.getByRole('heading', { name: 'Release Readiness', exact: true })).toBeVisible()
|
|
await expect(page.getByRole('link', { name: /E2E Project task/ })).toBeVisible()
|
|
await expect(page.getByText('E2E Unrelated task', { exact: true })).toHaveCount(0)
|
|
await expect(page.getByRole('link', { name: /Agent iris/ })).toHaveAttribute(
|
|
'href',
|
|
'/agents/iris',
|
|
)
|
|
await expect(page.getByRole('link', {
|
|
name: /Run Release readiness verification/,
|
|
})).toHaveAttribute(
|
|
'href',
|
|
'/runs/33333333-3333-4333-8333-333333333333',
|
|
)
|
|
await expect(page.getByText(
|
|
/A project reference is not delivered by the current incident contract/,
|
|
)).toBeVisible()
|
|
|
|
// The detail route is also a durable deep link, not only a client-side
|
|
// transition from the index.
|
|
await page.goto(`/projects/${PROJECT_ID}`)
|
|
await expect(page.getByRole('heading', { name: 'Release Readiness', exact: true })).toBeVisible()
|
|
})
|
|
|
|
test('deduplicates the shared OpenClaw overview across dashboard consumers', async ({ page }) => {
|
|
const api = await mockNexusApi(page, { role: 'owner' })
|
|
|
|
await page.goto('/dashboard')
|
|
await expect(page.getByRole('heading', {
|
|
name: 'Live-Orchestrierung',
|
|
exact: true,
|
|
})).toBeVisible()
|
|
|
|
await expect.poll(() => api.openClawOverviewRequestCount).toBe(1)
|
|
expect(api.browserTelemetryRequestCount).toBe(0)
|
|
})
|
|
|
|
test('distinguishes a dependency outage and recovers without hiding the route', async ({ page }, testInfo) => {
|
|
await mockNexusApi(page, {
|
|
role: 'owner',
|
|
apiFailure: {
|
|
path: '/api/v1/projects',
|
|
status: 503,
|
|
// Vue Query performs two bounded retries for safe reads. The visible
|
|
// recovery action is the next, explicit request.
|
|
attempts: 3,
|
|
},
|
|
})
|
|
|
|
await page.goto('/projects')
|
|
const recovery = page.locator('[data-state="offline"][data-problem-kind="offline"]')
|
|
await expect(recovery).toBeVisible()
|
|
await expect(recovery).toContainText('Abhängigkeit nicht erreichbar')
|
|
await page.screenshot({
|
|
path: testInfo.outputPath('dependency-recovery.png'),
|
|
fullPage: true,
|
|
})
|
|
await recovery.getByRole('button', { name: 'Erneut prüfen' }).click()
|
|
|
|
await expect(page.getByRole('link', { name: /Release Readiness/ })).toBeVisible()
|
|
await expect(recovery).toHaveCount(0)
|
|
})
|
|
|
|
test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => {
|
|
await mockNexusApi(page, { role: 'member' })
|
|
|
|
await page.goto('/dashboard?iris=1')
|
|
await expect(page).toHaveURL('/dashboard')
|
|
await expect(page.getByRole('button', {
|
|
name: 'Iris Chat ist nur für Owner verfügbar',
|
|
})).toBeDisabled()
|
|
await expect(page.locator('#iris-chat-dialog')).toHaveCount(0)
|
|
|
|
await page.keyboard.press('Control+k')
|
|
const commandDialog = page.getByRole('dialog', {
|
|
name: 'Mission Control commands',
|
|
})
|
|
await expect(commandDialog).toBeVisible()
|
|
await expect(commandDialog.getByRole('button', { name: /Ask Iris/ })).toHaveCount(0)
|
|
await expect(commandDialog.getByRole('button', { name: /Start OpenClaw run/ })).toHaveCount(0)
|
|
await page.keyboard.press('Escape')
|
|
|
|
await page.goto('/tasks?iris=1')
|
|
await expect(page).toHaveURL('/tasks')
|
|
await expect(page.getByRole('button', {
|
|
name: 'Iris chat is available to owners only',
|
|
})).toBeDisabled()
|
|
await expect(page.locator('#iris-chat-dialog')).toHaveCount(0)
|
|
})
|
|
|
|
test('resolves Activity and Notification results to the linked task', async ({ page }) => {
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
|
|
await page.goto('/activity')
|
|
await page.getByRole('button', { name: /Nexus task deep link is ready/ }).click()
|
|
const activityDialog = page.getByRole('dialog', { name: 'task.updated' })
|
|
const activityTaskLink = activityDialog.getByRole('link', {
|
|
name: /E2E Backlog task/,
|
|
})
|
|
await expect(activityTaskLink).toHaveAttribute('href', '/tasks/task-open-1')
|
|
await activityTaskLink.click()
|
|
await expect(page).toHaveURL('/tasks/task-open-1')
|
|
|
|
await page.goto('/notifications')
|
|
await page.getByRole('button', {
|
|
name: /Inspect E2E backlog task/,
|
|
}).click()
|
|
await expect(page).toHaveURL('/tasks/task-open-1')
|
|
await expect(page.getByRole('heading', {
|
|
level: 1,
|
|
name: 'E2E Backlog task',
|
|
})).toBeVisible()
|
|
})
|
|
|
|
test('links a Dashboard focus result directly to its task detail', async ({ page }) => {
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
|
|
await page.goto('/dashboard')
|
|
const taskLink = page.getByRole('link', {
|
|
name: /E2E Backlog task/,
|
|
})
|
|
await expect(taskLink).toHaveAttribute('href', '/tasks/task-open-1')
|
|
await taskLink.click()
|
|
|
|
await expect(page).toHaveURL('/tasks/task-open-1')
|
|
await expect(page.getByRole('heading', {
|
|
level: 1,
|
|
name: 'E2E Backlog task',
|
|
})).toBeVisible()
|
|
})
|
|
|
|
test('opens OpenClaw content with its Iris source deep link', async ({ page }) => {
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
|
|
await page.goto('/docs')
|
|
await page.getByRole('button', { name: new RegExp(DOC_PATH.split('/').at(-1)!) }).click()
|
|
const docContent = page.locator('.memory-rendered')
|
|
await expect(docContent.getByRole('heading', {
|
|
name: 'Mission Control Runbook',
|
|
})).toBeVisible()
|
|
await expect(docContent).toContainText('OpenClaw-backed documentation is visible in Nexus.')
|
|
await expect(page.locator('.source-context').getByRole('link', {
|
|
name: 'iris',
|
|
exact: true,
|
|
})).toHaveAttribute('href', '/agents/iris')
|
|
await expect(page.locator('.source-context')).toContainText(DOC_PATH)
|
|
|
|
await page.goto('/memory')
|
|
await page.getByRole('button', { name: new RegExp(MEMORY_NAME) }).click()
|
|
const memoryContent = page.locator('.memory-rendered')
|
|
await expect(memoryContent.getByRole('heading', {
|
|
name: 'Daily Memory',
|
|
})).toBeVisible()
|
|
await expect(memoryContent).toContainText('Agent-first context is loaded from Iris.')
|
|
await expect(page.locator('.source-context').getByRole('link', {
|
|
name: 'iris',
|
|
exact: true,
|
|
})).toHaveAttribute('href', '/agents/iris')
|
|
await expect(page.locator('.source-context')).toContainText(`memory/${MEMORY_NAME}`)
|
|
|
|
await page.goto('/incidents')
|
|
await page.getByRole('button', { name: /Gateway Timeout Recovered/ }).click()
|
|
const incidentContent = page.locator('.incident-rendered')
|
|
await expect(incidentContent.getByRole('heading', {
|
|
name: 'Gateway Timeout Recovered',
|
|
})).toBeVisible()
|
|
await expect(incidentContent).toContainText(
|
|
'The gateway recovered without duplicate provisioning.',
|
|
)
|
|
await expect(page.locator('.source-context').getByRole('link', {
|
|
name: 'iris',
|
|
exact: true,
|
|
})).toHaveAttribute('href', '/agents/iris')
|
|
await expect(page.locator('.source-context')).toContainText(
|
|
`memory/incidents/${INCIDENT_NAME}`,
|
|
)
|
|
})
|
|
})
|
|
|
|
const shellViewports = [
|
|
{ width: 375, height: 812 },
|
|
{ width: 768, height: 900 },
|
|
{ width: 1024, height: 900 },
|
|
{ width: 1440, height: 900 },
|
|
{ width: 1920, height: 1080 },
|
|
]
|
|
|
|
for (const viewport of shellViewports) {
|
|
test(`keeps every registered core route inside the ${viewport.width}px page shell`, async ({ page }) => {
|
|
test.setTimeout(120_000)
|
|
await page.setViewportSize(viewport)
|
|
await mockNexusApi(page, { role: 'owner' })
|
|
|
|
for (const route of coreRoutes) {
|
|
await page.goto(route.path)
|
|
await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
|
|
const geometry = await page.evaluate(() => ({
|
|
viewportWidth: document.documentElement.clientWidth,
|
|
rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
bodyOverflow: document.body.scrollWidth - document.documentElement.clientWidth,
|
|
}))
|
|
expect(
|
|
geometry.rootOverflow,
|
|
`${route.path} overflows the root at ${viewport.width}px`,
|
|
).toBeLessThanOrEqual(1)
|
|
expect(
|
|
geometry.bodyOverflow,
|
|
`${route.path} overflows the body at ${viewport.width}px`,
|
|
).toBeLessThanOrEqual(1)
|
|
}
|
|
})
|
|
}
|