306 lines
10 KiB
TypeScript
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([])
|
|
})
|
|
})
|