Files
DevRunbook-Public/tests/e2e/milestone-three.spec.ts
T
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

306 lines
10 KiB
TypeScript

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([])
})
})