This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { request, type FullConfig } from '@playwright/test'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
if (!email || !password) return
|
||||
const baseURL = config.projects[0]?.use.baseURL
|
||||
if (typeof baseURL !== 'string') {
|
||||
throw new Error('PLAYWRIGHT_BASE_URL is required for authenticated setup')
|
||||
}
|
||||
const context = await request.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: { origin: new URL(baseURL).origin },
|
||||
})
|
||||
try {
|
||||
const response = await context.post('/api/auth/sign-in/email', {
|
||||
data: { email, password },
|
||||
})
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Browser authentication failed with ${response.status()}`)
|
||||
}
|
||||
await mkdir('test-results/.auth', { recursive: true })
|
||||
await context.storageState({ path: 'test-results/.auth/m2.json' })
|
||||
} finally {
|
||||
await context.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
function profile(name: string) {
|
||||
return {
|
||||
apiVersion: 'devrunbook.io/v1alpha1',
|
||||
kind: 'RepositoryProfile',
|
||||
metadata: { name, revision: 1, source: 'manual' },
|
||||
spec: {
|
||||
repositoryType: 'single-app',
|
||||
defaultBranch: 'main',
|
||||
stack: {
|
||||
languages: ['TypeScript'],
|
||||
frameworks: ['Next.js'],
|
||||
packageManagers: ['pnpm'],
|
||||
databases: ['PostgreSQL'],
|
||||
deploymentTypes: ['Docker'],
|
||||
testFrameworks: ['Vitest', 'Playwright'],
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
id: 'verify',
|
||||
role: 'unit-test',
|
||||
command: 'pnpm test',
|
||||
workingDirectory: '.',
|
||||
platform: 'any',
|
||||
shell: 'auto',
|
||||
source: 'manual',
|
||||
confirmed: true,
|
||||
safeForAgentSuggestion: false,
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
applicationRoots: ['apps/web'],
|
||||
testRoots: ['tests'],
|
||||
documentationRoots: ['docs'],
|
||||
generated: ['dist'],
|
||||
protected: ['runtime/secrets'],
|
||||
excluded: ['node_modules'],
|
||||
},
|
||||
policies: {
|
||||
preserveBackwardCompatibility: true,
|
||||
newDependencies: 'justify',
|
||||
gitWrite: 'none',
|
||||
migrations: 'plan-only',
|
||||
documentationRequired: true,
|
||||
networkAccess: 'forbidden',
|
||||
productionDataAccess: 'forbidden',
|
||||
requiredValidationRoles: ['unit-test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function origin(testInfo: { project: { use: { baseURL?: string } } }) {
|
||||
return new URL(String(testInfo.project.use.baseURL)).origin
|
||||
}
|
||||
|
||||
async function createRepository(
|
||||
request: APIRequestContext,
|
||||
requestOrigin: string,
|
||||
name: string,
|
||||
) {
|
||||
const response = await request.post('/api/v1/repositories', {
|
||||
headers: { origin: requestOrigin },
|
||||
data: { displayName: name, initialProfile: profile(name) },
|
||||
})
|
||||
expect(response.status()).toBe(201)
|
||||
return {
|
||||
body: (await response.json()) as {
|
||||
repository: { id: string; currentProfileRevision: number | null }
|
||||
currentProfile: {
|
||||
revision: number
|
||||
profile: ReturnType<typeof profile> & {
|
||||
metadata: { contentDigest: string }
|
||||
}
|
||||
}
|
||||
},
|
||||
etag: response.headers().etag,
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('authenticated Milestone 3 repository profiles', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test('immutable HTTP lifecycle, conflicts, exports and re-imports are governed', async ({
|
||||
request,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const requestOrigin = origin(testInfo)
|
||||
const name = `API evidence ${Date.now()}`
|
||||
const created = await createRepository(request, requestOrigin, name)
|
||||
const repositoryId = created.body.repository.id
|
||||
expect(created.etag).toMatch(/^"profile:1:[0-9a-f]{64}"$/u)
|
||||
expect(created.body.currentProfile.revision).toBe(1)
|
||||
|
||||
const listed = await request.get('/api/v1/repositories?source=manual')
|
||||
expect(listed.status()).toBe(200)
|
||||
expect(
|
||||
(
|
||||
(await listed.json()) as {
|
||||
items: { id: string; currentProfileRevision: number | null }[]
|
||||
}
|
||||
).items,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ id: repositoryId, currentProfileRevision: 1 }),
|
||||
)
|
||||
|
||||
const current = await request.get(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
)
|
||||
expect(current.status()).toBe(200)
|
||||
expect(current.headers().etag).toBe(created.etag)
|
||||
const currentBody = (await current.json()) as {
|
||||
revision: number
|
||||
profile: ReturnType<typeof profile>
|
||||
}
|
||||
|
||||
const noOp = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: currentBody.profile,
|
||||
},
|
||||
)
|
||||
expect(noOp.status()).toBe(200)
|
||||
expect((await noOp.json()).revision).toBe(1)
|
||||
|
||||
const changedProfile = {
|
||||
...currentBody.profile,
|
||||
spec: {
|
||||
...currentBody.profile.spec,
|
||||
paths: {
|
||||
...currentBody.profile.spec.paths,
|
||||
protected: ['runtime/secrets', '.github/workflows'],
|
||||
},
|
||||
},
|
||||
}
|
||||
const revised = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: changedProfile,
|
||||
},
|
||||
)
|
||||
expect(revised.status()).toBe(201)
|
||||
expect((await revised.json()).revision).toBe(2)
|
||||
const revisedEtag = revised.headers().etag
|
||||
expect(revisedEtag).toMatch(/^"profile:2:[0-9a-f]{64}"$/u)
|
||||
|
||||
const stale = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': created.etag },
|
||||
data: changedProfile,
|
||||
},
|
||||
)
|
||||
expect(stale.status()).toBe(409)
|
||||
await expect(stale.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 'repository_profile_conflict',
|
||||
details: [
|
||||
expect.objectContaining({
|
||||
currentRevision: 2,
|
||||
currentEtag: revisedEtag,
|
||||
recovery: 'reload-and-review',
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const missingPrecondition = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{ headers: { origin: requestOrigin }, data: changedProfile },
|
||||
)
|
||||
expect(missingPrecondition.status()).toBe(428)
|
||||
|
||||
for (const format of ['json', 'yaml'] as const) {
|
||||
const exported = await request.get(
|
||||
`/api/v1/repositories/${repositoryId}/profile/export?format=${format}`,
|
||||
)
|
||||
expect(exported.status()).toBe(200)
|
||||
expect(exported.headers().etag).toBe(revisedEtag)
|
||||
const reimported = await request.post('/api/v1/repositories', {
|
||||
headers: {
|
||||
origin: requestOrigin,
|
||||
'content-type':
|
||||
format === 'json' ? 'application/json' : 'application/yaml',
|
||||
},
|
||||
data: await exported.body(),
|
||||
})
|
||||
expect(reimported.status()).toBe(201)
|
||||
}
|
||||
|
||||
const invalid = {
|
||||
...changedProfile,
|
||||
spec: {
|
||||
...changedProfile.spec,
|
||||
paths: { ...changedProfile.spec.paths, protected: ['../outside'] },
|
||||
},
|
||||
}
|
||||
const rejected = await request.put(
|
||||
`/api/v1/repositories/${repositoryId}/profile`,
|
||||
{
|
||||
headers: { origin: requestOrigin, 'if-match': revisedEtag },
|
||||
data: invalid,
|
||||
},
|
||||
)
|
||||
expect(rejected.status()).toBe(422)
|
||||
expect(await rejected.text()).toContain('/spec/paths/protected/0')
|
||||
|
||||
const crossOrigin = await request.post('/api/v1/repositories', {
|
||||
headers: { origin: 'https://attacker.example' },
|
||||
data: { displayName: 'Rejected', initialProfile: profile('Rejected') },
|
||||
})
|
||||
expect(crossOrigin.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('manual UI creation, immutable editing and composer context remain usable', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
const name = `Browser evidence ${Date.now()}`
|
||||
await page.goto('/repositories/new')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Create a repository profile',
|
||||
}),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/Nothing entered here is executed/u),
|
||||
).toBeVisible()
|
||||
await page.getByLabel('Display name').fill(name)
|
||||
await page.getByRole('button', { name: 'Create repository' }).click()
|
||||
await expect(page).toHaveURL(/\/repositories\/[0-9a-f-]+$/u)
|
||||
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible()
|
||||
await expect(page.getByText('Immutable profile revision 1')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/does not run repository commands/u),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Edit profile' }).click()
|
||||
await page
|
||||
.getByLabel('Protected paths')
|
||||
.fill('runtime/secrets, .github/workflows')
|
||||
await page.getByRole('button', { name: 'Save immutable revision' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('Revision 2 saved')
|
||||
await page.getByRole('link', { name: 'Back to repository' }).click()
|
||||
await expect(page.getByText('Immutable profile revision 2')).toBeVisible()
|
||||
await expect(page.getByText('.github/workflows')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Create task' }).click()
|
||||
await expect(page).toHaveURL(/\/composer\/new\?repository=/u)
|
||||
await expect(page.getByText('Repository context retained')).toBeVisible()
|
||||
await expect(page.getByText('.github/workflows')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('narrow keyboard, theme and reduced-motion behavior stay operable', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/repositories')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Repositories' }),
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.keyboard.press('Control+K')
|
||||
const palette = page.getByRole('dialog', { name: 'Command palette' })
|
||||
await expect(palette).toBeVisible()
|
||||
await palette.getByRole('combobox').fill('repositories')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/repositories$/u)
|
||||
await page.getByRole('button', { name: 'Use light theme' }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
test.describe('authenticated Milestone 2 library', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/library')
|
||||
await expect(page).toHaveURL(/\/library(?:\?.*)?$/u)
|
||||
})
|
||||
|
||||
test('search, filter, view and refresh state remain URL-backed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Library' }),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-playbook-slug]')).toHaveCount(28)
|
||||
await page.getByLabel('Search playbooks').fill('root cause')
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||
await expect(page).toHaveURL(/q=root(?:\+|%20)cause/u)
|
||||
await expect(
|
||||
page.locator('[data-playbook-slug="root-cause-bugfix"]'),
|
||||
).toBeVisible()
|
||||
await page.getByRole('link', { name: 'Dense' }).click()
|
||||
await expect(page).toHaveURL(/view=dense/u)
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-view="dense"]')).toBeVisible()
|
||||
await expect(page.getByLabel('Search playbooks')).toHaveValue('root cause')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('favorites persist and can be recovered through the saved filter', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'chromium', 'mutation is covered once')
|
||||
const card = page.locator('[data-playbook-slug="root-cause-bugfix"]')
|
||||
const favorite = card.getByRole('button', { name: /favorites/i })
|
||||
const initiallySaved =
|
||||
(await favorite.getAttribute('aria-pressed')) === 'true'
|
||||
if (!initiallySaved) await favorite.click()
|
||||
await expect(favorite).toHaveAttribute('aria-pressed', 'true')
|
||||
await page.getByLabel('Saved playbooks only').check()
|
||||
await page.getByRole('button', { name: 'Apply filters' }).click()
|
||||
await expect(page).toHaveURL(/favorites=true/u)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: /favorites/i }).click()
|
||||
await expect(
|
||||
card.getByRole('button', { name: /favorites/i }),
|
||||
).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
test('detail exposes governance panels and version-bound composer handoff', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/library/root-cause-bugfix')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /root.?cause/i }),
|
||||
).toBeVisible()
|
||||
for (const panel of [
|
||||
'Intent and outcome',
|
||||
'Inputs and defaults',
|
||||
'Repository compatibility',
|
||||
'Guardrails',
|
||||
'Workflow',
|
||||
'Validation requirements',
|
||||
'Completion criteria',
|
||||
'Quality and limitations',
|
||||
'Package files',
|
||||
'Version history',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 2, name: panel }),
|
||||
).toBeVisible()
|
||||
}
|
||||
await page.getByRole('link', { name: /Compose with this version/i }).click()
|
||||
await expect(page).toHaveURL(
|
||||
/\/composer\/new\?playbook=root-cause-bugfix&version=1\.0\.0/u,
|
||||
)
|
||||
await expect(page.getByText('root-cause-bugfix@1.0.0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('command palette, theme and narrow keyboard layout are operable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/library')
|
||||
await page.keyboard.press('Control+K')
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Command palette' }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Command palette' })
|
||||
.getByRole('combobox')
|
||||
.fill('root cause')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/library\/root-cause-bugfix$/u)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.getByRole('button', { name: 'Use light theme' }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
await page.reload()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('catalog shell renders the verified built-in slice', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveTitle(/DevRunbook/)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Turn intent into a verifiable change contract/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('28 built-ins · no Gitea required')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 3 })).toHaveCount(6)
|
||||
await expect(page.locator('[data-nextjs-dialog]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('catalog API requires a local authenticated workspace', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/api/v1/playbooks')
|
||||
expect(response.status()).toBe(401)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: { code: 'authentication_required' },
|
||||
})
|
||||
})
|
||||
|
||||
test('legacy playbook links preserve the authentication boundary', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/playbooks/root-cause-bugfix')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /continue with your local account/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
expect(new URL(page.url()).pathname).toBe('/login')
|
||||
})
|
||||
|
||||
test('narrow layout has no horizontal overflow and keyboard focus is visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /DevRunbook/i })).toBeFocused()
|
||||
const outline = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as Element).outlineStyle,
|
||||
)
|
||||
expect(outline).not.toBe('none')
|
||||
})
|
||||
|
||||
test('security headers are present on application pages', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/')
|
||||
expect(response.headers()['x-content-type-options']).toBe('nosniff')
|
||||
expect(response.headers()['x-frame-options']).toBe('DENY')
|
||||
expect(response.headers()['content-security-policy']).toContain(
|
||||
"frame-ancestors 'none'",
|
||||
)
|
||||
})
|
||||
|
||||
test('tokenless bootstrap is denied through an untrusted proxy boundary', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post('/api/v1/instance/setup', {
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
data: {
|
||||
bootstrapToken: '',
|
||||
instanceName: 'Test instance',
|
||||
publicBaseUrl: 'http://127.0.0.1:3000',
|
||||
owner: {
|
||||
email: 'owner@example.test',
|
||||
displayName: 'Owner',
|
||||
password: 'not-a-real-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
expect(await response.text()).not.toContain('not-a-real-password')
|
||||
})
|
||||
|
||||
test('setup UI exposes the database recovery state without enabling credentials', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
Boolean(process.env.DEVRUNBOOK_E2E_EMAIL),
|
||||
'recovery state requires an uninitialized validation database',
|
||||
)
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
await page.goto('/setup')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Make this instance yours.' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('recovery required')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
await expect(page.getByLabel('Owner email')).toBeDisabled()
|
||||
await expect(page.getByText(/Gitea is optional/i)).toBeVisible()
|
||||
expect(consoleErrors).toEqual([])
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
test('local sign-in fails generically and clears credentials when auth is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
|
||||
await page.goto('/login?returnTo=https%3A%2F%2Fattacker.example%2Fsteal')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /continue with your local account/i,
|
||||
}),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByLabel('Email address').fill('owner@example.test')
|
||||
const password = page.getByLabel('Password', { exact: true })
|
||||
await password.fill('not-a-real-password')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page.locator('form').getByRole('alert')).toHaveText(
|
||||
'Sign-in could not be completed. Check your credentials or try again later.',
|
||||
)
|
||||
await expect(password).toHaveValue('')
|
||||
expect(new URL(page.url()).pathname).toBe('/login')
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
test('password recovery keeps its token out of HTTP and clears both credentials', async ({
|
||||
page,
|
||||
}) => {
|
||||
const token = 'a'.repeat(43)
|
||||
const requestedUrls: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('request', (request) => requestedUrls.push(request.url()))
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
|
||||
await page.goto(`/reset-password#token=${token}`)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /restore access/i }),
|
||||
).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/reset-password$/u)
|
||||
expect(requestedUrls.every((url) => !url.includes(token))).toBe(true)
|
||||
|
||||
const password = page.getByLabel('New password', { exact: true })
|
||||
const confirmation = page.getByLabel('Confirm new password')
|
||||
await password.fill('a secure replacement password')
|
||||
await confirmation.fill('a secure replacement password')
|
||||
await page.getByRole('button', { name: 'Update password' }).click()
|
||||
|
||||
await expect(page.locator('form').getByRole('alert')).toContainText(
|
||||
'invalid, expired, or already used',
|
||||
)
|
||||
await expect(password).toHaveValue('')
|
||||
await expect(confirmation).toHaveValue('')
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const email = process.env.DEVRUNBOOK_E2E_EMAIL
|
||||
const password = process.env.DEVRUNBOOK_E2E_PASSWORD
|
||||
|
||||
const criticalRoutes = [
|
||||
'/start',
|
||||
'/composer/new',
|
||||
'/repositories',
|
||||
'/runs',
|
||||
'/account',
|
||||
'/account/security',
|
||||
'/repositories/new',
|
||||
'/management',
|
||||
] as const
|
||||
|
||||
test.describe('Phase 14 accessibility regression', () => {
|
||||
test.skip(!email || !password, 'live local-account credentials are required')
|
||||
test.use({
|
||||
storageState: 'test-results/.auth/m2.json',
|
||||
reducedMotion: 'reduce',
|
||||
})
|
||||
|
||||
for (const route of criticalRoutes) {
|
||||
test(`${route} has one main and no serious accessibility violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(route)
|
||||
await expect(page.locator('main')).toHaveCount(1)
|
||||
await expect(page.locator('[id]')).toHaveCount(
|
||||
await page
|
||||
.locator('[id]')
|
||||
.evaluateAll(
|
||||
(elements) => new Set(elements.map((element) => element.id)).size,
|
||||
),
|
||||
)
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze()
|
||||
expect(
|
||||
results.violations.filter(
|
||||
({ impact }) => impact === 'critical' || impact === 'serious',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('Start remains keyboard complete, reflows and exposes touch targets', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/start')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator(':focus-visible')).toHaveCount(1)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
const undersizedPrimaryTargets = await page
|
||||
.locator('main button, main a[href="/settings/integrations/gitea/new"]')
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
(box.width < 44 || box.height < 44)
|
||||
)
|
||||
})
|
||||
.map(
|
||||
(element) =>
|
||||
element.getAttribute('aria-label') ?? element.textContent,
|
||||
),
|
||||
)
|
||||
expect(undersizedPrimaryTargets).toEqual([])
|
||||
})
|
||||
|
||||
test('critical pages reflow at a 200 percent equivalent CSS viewport', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 640, height: 720 })
|
||||
for (const route of criticalRoutes) {
|
||||
await page.goto(route)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
for (const locale of ['en', 'nl'] as const) {
|
||||
for (const mode of ['simple', 'expert'] as const) {
|
||||
test(`${locale}/${mode} presentation stays accessible`, async ({
|
||||
page,
|
||||
context,
|
||||
baseURL,
|
||||
}) => {
|
||||
const origin = new URL(baseURL ?? 'http://127.0.0.1:3000')
|
||||
await context.addCookies([
|
||||
{ name: 'devrunbook_locale', value: locale, url: origin.origin },
|
||||
{ name: 'devrunbook_mode', value: mode, url: origin.origin },
|
||||
])
|
||||
await page.goto('/start')
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name:
|
||||
locale === 'nl' ? 'Wat wil je doen?' : 'What do you want to do?',
|
||||
}),
|
||||
).toBeVisible()
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze()
|
||||
expect(
|
||||
results.violations.filter(
|
||||
({ impact }) => impact === 'critical' || impact === 'serious',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const hasCredentials = Boolean(
|
||||
process.env.DEVRUNBOOK_E2E_EMAIL && process.env.DEVRUNBOOK_E2E_PASSWORD,
|
||||
)
|
||||
|
||||
test.describe('Usability recovery', () => {
|
||||
test.skip(!hasCredentials, 'live local-account credentials are required')
|
||||
test.use({ storageState: 'test-results/.auth/m2.json' })
|
||||
|
||||
test.beforeEach(async ({ context, baseURL }) => {
|
||||
if (!baseURL) throw new Error('A browser base URL is required')
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'devrunbook_locale',
|
||||
value: 'nl',
|
||||
url: baseURL,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('Task library is compact and understandable on mobile', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/library')
|
||||
|
||||
await expect(page).toHaveTitle('Taakbibliotheek · DevRunbook')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Kies een bewezen taak' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('.drb-library-filters details'),
|
||||
).not.toHaveAttribute('open', '')
|
||||
await expect(page.locator('.drb-playbook-card')).toHaveCount(8)
|
||||
await expect(page.getByRole('button', { name: /Meer tonen/ })).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const undersizedNavigationTargets = await page
|
||||
.locator(
|
||||
'.drb-app-shell__header button, .drb-app-shell__header a, .drb-app-shell__mobile-tabs a',
|
||||
)
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
box.width > 0 &&
|
||||
box.height > 0 &&
|
||||
(box.width < 44 || box.height < 44)
|
||||
)
|
||||
})
|
||||
.map((element) => element.textContent?.trim()),
|
||||
)
|
||||
expect(undersizedNavigationTargets).toEqual([])
|
||||
})
|
||||
|
||||
test('Dutch shell, account and role context use ordinary language', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/account')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'DevRunbook startpagina' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByLabel('Actieve werkruimte')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Jouw account' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Rol in deze werkruimte')).toBeVisible()
|
||||
|
||||
await page.goto('/account/security')
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Wachtwoord en sessies' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Wachtwoord veilig herstellen' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
test('Start action does not cover mobile content', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/start')
|
||||
await expect(page).toHaveTitle('Nieuwe taak · DevRunbook')
|
||||
expect(
|
||||
await page.locator('main').evaluate((main) => {
|
||||
const stickyElements = [...main.querySelectorAll('*')].filter(
|
||||
(element) => getComputedStyle(element).position === 'sticky',
|
||||
)
|
||||
return stickyElements.length
|
||||
}),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user