feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { mockNexusApi, PROPOSAL_ID } from './support/nexusApi'
|
||||
|
||||
test.describe('agent proposal lifecycle', () => {
|
||||
test('an owner creates, reviews, and approves a proposal with mutation guards', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, { role: 'owner' })
|
||||
await page.goto('/agents/new')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Agent vorschlagen' })).toBeVisible()
|
||||
await page.getByRole('textbox', { name: 'Name Pflichtfeld', exact: true }).fill('Release Sentinel')
|
||||
await page.getByLabel('Rolle').fill('Release Operations')
|
||||
await page.getByLabel('Zweck und Verantwortungsbereich').fill(
|
||||
'Verifies release readiness and reports blockers.',
|
||||
)
|
||||
await page.getByLabel('OpenClaw-Modell').selectOption('openai/gpt-5.4')
|
||||
await page.getByLabel('Datei').selectOption('SOUL.md')
|
||||
await page.getByLabel('SOUL.md Inhalt').fill('# Release Sentinel\nVerify before reporting.')
|
||||
await page.getByRole('button', { name: 'Vorschlag anlegen' }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/agents/proposals/${PROPOSAL_ID}$`))
|
||||
await expect(page.getByText('Freigabe offen', { exact: true })).toBeVisible()
|
||||
|
||||
const createRequest = api.proposalRequests.find(request =>
|
||||
request.path === '/api/v1/openclaw/agent-proposals'
|
||||
)
|
||||
expect(createRequest).toBeDefined()
|
||||
expect(createRequest?.headers['idempotency-key']).toBeTruthy()
|
||||
expect(createRequest?.headers['x-correlation-id']).toBeTruthy()
|
||||
expect(createRequest?.body).toMatchObject({
|
||||
name: 'Release Sentinel',
|
||||
role: 'Release Operations',
|
||||
model: 'openai/gpt-5.4',
|
||||
files: {
|
||||
'SOUL.md': '# Release Sentinel\nVerify before reporting.',
|
||||
},
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Freigeben' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Agent-Provisionierung freigeben' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByLabel('Notiz (optional)').fill('Reviewed in the owner E2E flow.')
|
||||
await dialog.getByRole('button', { name: 'Bestätigen' }).click()
|
||||
|
||||
await expect(page.getByText('Bereit', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Agent ist bereit.', { exact: true })).toBeVisible()
|
||||
|
||||
const approveRequest = api.proposalRequests.find(request =>
|
||||
request.path.endsWith('/approve')
|
||||
)
|
||||
expect(approveRequest).toBeDefined()
|
||||
expect(approveRequest?.headers['idempotency-key']).toBeTruthy()
|
||||
expect(approveRequest?.headers['x-correlation-id']).toBeTruthy()
|
||||
expect(approveRequest?.body).toMatchObject({
|
||||
expectedRevision: 1,
|
||||
reason: 'Reviewed in the owner E2E flow.',
|
||||
})
|
||||
})
|
||||
|
||||
test('a non-owner sees the permission boundary without owner API traffic', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, { role: 'member' })
|
||||
|
||||
await page.goto('/agents')
|
||||
await expect(page.getByText(
|
||||
'Agent-Vorschläge und OpenClaw-Provisionierung sind ausschließlich für Owner sichtbar.',
|
||||
)).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Agent vorschlagen' })).toHaveCount(0)
|
||||
|
||||
await page.goto('/agents/new')
|
||||
await expect(page.getByRole('alert')).toContainText('Owner-Berechtigung erforderlich')
|
||||
await expect(page.getByRole('button', { name: 'Vorschlag anlegen' })).toHaveCount(0)
|
||||
|
||||
await page.goto(`/agents/proposals/${PROPOSAL_ID}`)
|
||||
await expect(page.getByRole('alert')).toContainText('Owner-Berechtigung erforderlich')
|
||||
await expect(page.getByRole('button', { name: 'Freigeben' })).toHaveCount(0)
|
||||
|
||||
expect(api.proposalRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('an Iris proposal event becomes a structured approval card and deep link', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, { role: 'owner' })
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await page.getByRole('button', { name: 'Iris Chat öffnen' }).click()
|
||||
const chat = page.getByRole('dialog', { name: 'Iris Chat' })
|
||||
await expect(chat).toBeVisible()
|
||||
await chat.getByRole('textbox', { name: 'Nachricht an Iris' }).fill(
|
||||
'Schlage einen Release Sentinel Agenten vor, aber provisioniere ihn nicht.',
|
||||
)
|
||||
await chat.getByRole('button', { name: 'Nachricht senden' }).click()
|
||||
await expect(chat.getByText('OpenClaw accepted the Iris request.')).toBeVisible()
|
||||
expect(api.chatRequests).toHaveLength(1)
|
||||
expect(api.chatRequests[0]?.body).toMatchObject({
|
||||
agentId: 'iris',
|
||||
message: 'Schlage einen Release Sentinel Agenten vor, aber provisioniere ihn nicht.',
|
||||
})
|
||||
expect(api.proposalRequests).toEqual([])
|
||||
|
||||
await page.evaluate(({ proposalId, occurredAt }) => {
|
||||
window.dispatchEvent(new CustomEvent('nexus:domain-event', {
|
||||
detail: {
|
||||
sequence: 73,
|
||||
eventType: 'agent.proposal.created',
|
||||
entity: {
|
||||
type: 'agent-proposal',
|
||||
id: proposalId,
|
||||
label: 'Release Sentinel',
|
||||
},
|
||||
entityRevision: 1,
|
||||
occurredAt,
|
||||
payload: { state: 'awaiting_approval' },
|
||||
},
|
||||
}))
|
||||
}, { proposalId: PROPOSAL_ID, occurredAt: '2026-07-31T10:00:00.000Z' })
|
||||
|
||||
await expect(chat.getByText(
|
||||
'Iris hat einen Agent-Vorschlag zur Owner-Freigabe angelegt.',
|
||||
)).toBeVisible()
|
||||
const proposalLink = chat.getByRole('link', {
|
||||
name: /Agent-Vorschlag Release Sentinel/,
|
||||
})
|
||||
await expect(proposalLink).toHaveAttribute(
|
||||
'href',
|
||||
`/agents/proposals/${PROPOSAL_ID}`,
|
||||
)
|
||||
await proposalLink.click()
|
||||
|
||||
await expect(page).toHaveURL(`/agents/proposals/${PROPOSAL_ID}`)
|
||||
await expect(page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Release Sentinel',
|
||||
})).toBeVisible()
|
||||
await expect(chat).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
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('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)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { mockNexusApi } from './support/nexusApi'
|
||||
|
||||
test.describe('task board query lifecycle', () => {
|
||||
test('shows authoritative data after refresh and appends Done pagination', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, { role: 'owner' })
|
||||
await page.goto('/tasks')
|
||||
const board = page.locator('.board-columns')
|
||||
|
||||
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('E2E Done page one', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('E2E Done page two', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText('Revision board-r1', { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Weitere erledigte laden' }).click()
|
||||
await expect(board.getByText('E2E Done page two', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Weitere erledigte laden' })).toHaveCount(0)
|
||||
expect(api.boardRequestUrls.some(url => url.includes('doneCursor=done-page-2'))).toBe(true)
|
||||
|
||||
await page.reload()
|
||||
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('E2E Done page one', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Weitere erledigte laden' })).toBeVisible()
|
||||
expect(api.boardRequestUrls.filter(url => !url.includes('doneCursor=')).length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('advances the SSE cursor and performs exactly one visible-data resync', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, {
|
||||
role: 'owner',
|
||||
domainResyncSequence: 25,
|
||||
boardRefreshDelayMs: 700,
|
||||
boardRefreshTitle: 'E2E Backlog task refreshed',
|
||||
})
|
||||
await page.goto('/tasks')
|
||||
const board = page.locator('.board-columns')
|
||||
|
||||
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
await expect.poll(
|
||||
() => api.boardRequestUrls.filter(url => !url.includes('doneCursor=')).length,
|
||||
).toBe(2)
|
||||
|
||||
// A background refresh must retain the authoritative snapshot instead of
|
||||
// replacing it with a loading state.
|
||||
await expect(board.getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('E2E Backlog task refreshed', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Revision board-r2', { exact: true })).toBeVisible()
|
||||
|
||||
await expect.poll(() => api.domainEventRequests.length).toBeGreaterThanOrEqual(2)
|
||||
expect(api.domainEventRequests[0]?.lastEventId).toBeNull()
|
||||
expect(api.domainEventRequests.slice(1).every(item => item.lastEventId === '25')).toBe(true)
|
||||
expect(api.resyncResponseCount).toBe(1)
|
||||
|
||||
// Give another reconnect enough time to prove the stale cursor does not
|
||||
// trigger a second resync or a second REST refresh.
|
||||
await page.waitForTimeout(1_200)
|
||||
expect(api.resyncResponseCount).toBe(1)
|
||||
expect(api.boardRequestUrls.filter(url => !url.includes('doneCursor='))).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('persists a drag-and-drop move and keeps the card in its new column', async ({ page }) => {
|
||||
const api = await mockNexusApi(page, { role: 'owner' })
|
||||
await page.goto('/tasks')
|
||||
|
||||
const source = page.locator('.card').filter({ hasText: 'E2E Backlog task' }).first()
|
||||
const reviewColumn = page.locator('.col').filter({
|
||||
has: page.locator('.col-name', { hasText: 'Review' }),
|
||||
})
|
||||
await expect(source).toBeVisible()
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
|
||||
await source.dispatchEvent('dragstart', { dataTransfer })
|
||||
await reviewColumn.dispatchEvent('dragover', { dataTransfer })
|
||||
await reviewColumn.dispatchEvent('drop', { dataTransfer })
|
||||
await source.dispatchEvent('dragend', { dataTransfer })
|
||||
|
||||
await expect.poll(() => api.taskMutationRequests.length).toBe(1)
|
||||
expect(api.taskMutationRequests[0]?.path).toBe('/api/dashboard/tasks/task-open-1/move')
|
||||
expect(api.taskMutationRequests[0]?.body).toEqual({ state: 'review' })
|
||||
await expect(reviewColumn.getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.col').filter({
|
||||
has: page.locator('.col-name', { hasText: 'Offen' }),
|
||||
}).getByText('E2E Backlog task', { exact: true }),
|
||||
).toHaveCount(0)
|
||||
|
||||
const operationTray = page.getByRole('complementary', {
|
||||
name: 'Letztes Operationsergebnis',
|
||||
})
|
||||
await expect(operationTray.getByText('Task verschoben', { exact: true })).toBeVisible()
|
||||
const taskResultLink = operationTray.getByRole('link', { name: /E2E Backlog task/ })
|
||||
await expect(taskResultLink).toHaveAttribute('href', '/tasks/task-open-1')
|
||||
await taskResultLink.click()
|
||||
await expect(page).toHaveURL('/tasks/task-open-1')
|
||||
})
|
||||
})
|
||||
|
||||
const viewports = [
|
||||
{ width: 375, height: 812 },
|
||||
{ width: 768, height: 900 },
|
||||
{ width: 1024, height: 900 },
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 1920, height: 1080 },
|
||||
]
|
||||
|
||||
for (const viewport of viewports) {
|
||||
test(`contains horizontal board scrolling at ${viewport.width}px without page overflow`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
await mockNexusApi(page, { role: 'owner' })
|
||||
await page.goto('/tasks')
|
||||
await expect(page.locator('.board-columns').getByText('E2E Backlog task', { exact: true })).toBeVisible()
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
const main = document.querySelector<HTMLElement>('.legacy-main')
|
||||
const scroller = document.querySelector<HTMLElement>('.board-columns')
|
||||
if (!main || !scroller) throw new Error('Expected Task Board geometry was not rendered.')
|
||||
const mainRect = main.getBoundingClientRect()
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
scroller.scrollLeft = 100
|
||||
return {
|
||||
viewportWidth: root.clientWidth,
|
||||
rootOverflow: root.scrollWidth - root.clientWidth,
|
||||
bodyOverflow: body.scrollWidth - root.clientWidth,
|
||||
mainLeft: mainRect.left,
|
||||
mainRight: mainRect.right,
|
||||
scrollerLeft: scrollerRect.left,
|
||||
scrollerRight: scrollerRect.right,
|
||||
scrollerClientWidth: scroller.clientWidth,
|
||||
scrollerScrollWidth: scroller.scrollWidth,
|
||||
scrollerScrollLeft: scroller.scrollLeft,
|
||||
scrollerOverflowX: getComputedStyle(scroller).overflowX,
|
||||
}
|
||||
})
|
||||
|
||||
expect(geometry.rootOverflow).toBeLessThanOrEqual(1)
|
||||
expect(geometry.bodyOverflow).toBeLessThanOrEqual(1)
|
||||
expect(geometry.mainLeft).toBeGreaterThanOrEqual(-1)
|
||||
expect(geometry.mainRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
|
||||
expect(geometry.scrollerLeft).toBeGreaterThanOrEqual(-1)
|
||||
expect(geometry.scrollerRight).toBeLessThanOrEqual(geometry.viewportWidth + 1)
|
||||
expect(geometry.scrollerScrollWidth).toBeGreaterThanOrEqual(geometry.scrollerClientWidth)
|
||||
expect(geometry.scrollerOverflowX).toBe('auto')
|
||||
if (viewport.width <= 1024) {
|
||||
expect(geometry.scrollerScrollWidth - geometry.scrollerClientWidth).toBeGreaterThan(1)
|
||||
expect(geometry.scrollerScrollLeft).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../playwright.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user