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