Skip to content

Commit bb6cd5a

Browse files
feat: open pr styles and ux.
1 parent 23f96fa commit bb6cd5a

8 files changed

Lines changed: 1628 additions & 47 deletions

File tree

playwright/app.spec.ts

Lines changed: 187 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ type ChatRequestBody = {
1717
stream?: boolean
1818
}
1919

20+
type CreateRefRequestBody = {
21+
ref?: string
22+
sha?: string
23+
}
24+
25+
type PullRequestCreateBody = {
26+
head?: string
27+
base?: string
28+
}
29+
2030
const waitForAppReady = async (page: Page, path = appEntryPath) => {
2131
await page.goto(path)
2232
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
23-
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
2433
}
2534

2635
const waitForInitialRender = async (page: Page) => {
@@ -124,6 +133,18 @@ const ensureAiChatDrawerOpen = async (page: Page) => {
124133
await expect(page.locator('#ai-chat-drawer')).toBeVisible()
125134
}
126135

136+
const ensureOpenPrDrawerOpen = async (page: Page) => {
137+
const toggle = page.locator('#github-pr-toggle')
138+
await expect(toggle).toBeEnabled({ timeout: 60_000 })
139+
const isExpanded = await toggle.getAttribute('aria-expanded')
140+
141+
if (isExpanded !== 'true') {
142+
await toggle.click()
143+
}
144+
145+
await expect(page.locator('#github-pr-drawer')).toBeVisible()
146+
}
147+
127148
const connectByotWithSingleRepo = async (page: Page) => {
128149
await page.route('https://api.github.com/user/repos**', async route => {
129150
await route.fulfill({
@@ -144,9 +165,8 @@ const connectByotWithSingleRepo = async (page: Page) => {
144165

145166
await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890')
146167
await page.locator('#github-token-add').click()
147-
await expect(page.locator('#github-repo-select')).toHaveValue(
148-
'knightedcodemonkey/develop',
149-
)
168+
await expect(page.locator('#status')).toHaveText('Loaded 1 writable repositories')
169+
await expect(page.locator('#github-pr-toggle')).toBeVisible()
150170
}
151171

152172
const expectCollapseButtonState = async (
@@ -187,6 +207,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page })
187207
await expect(byotControls).toBeHidden()
188208
await expect(page.locator('#ai-chat-toggle')).toBeHidden()
189209
await expect(page.locator('#ai-chat-drawer')).toBeHidden()
210+
await expect(page.locator('#github-pr-toggle')).toBeHidden()
211+
await expect(page.locator('#github-pr-drawer')).toBeHidden()
190212
})
191213

192214
test('BYOT controls render when feature flag is enabled by query param', async ({
@@ -199,6 +221,7 @@ test('BYOT controls render when feature flag is enabled by query param', async (
199221
await expect(page.locator('#github-token-input')).toBeVisible()
200222
await expect(page.locator('#github-token-add')).toBeVisible()
201223
await expect(page.locator('#github-ai-controls #ai-chat-toggle')).toBeHidden()
224+
await expect(page.locator('#github-ai-controls #github-pr-toggle')).toBeHidden()
202225
})
203226

204227
test('GitHub token info panel reflects missing and present token states', async ({
@@ -476,6 +499,8 @@ test('AI chat falls back to non-streaming response when streaming fails', async
476499
})
477500

478501
test('BYOT remembers selected repository across reloads', async ({ page }) => {
502+
test.setTimeout(90_000)
503+
479504
await page.route('https://api.github.com/user/repos**', async route => {
480505
await route.fulfill({
481506
status: 200,
@@ -506,7 +531,9 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
506531
await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
507532
await page.locator('#github-token-add').click()
508533

509-
const repoSelect = page.locator('#github-repo-select')
534+
await ensureOpenPrDrawerOpen(page)
535+
536+
const repoSelect = page.locator('#github-pr-repo-select')
510537
await expect(repoSelect).toBeEnabled()
511538
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')
512539

@@ -515,11 +542,166 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
515542

516543
await page.reload()
517544
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
545+
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories', {
546+
timeout: 60_000,
547+
})
518548
await expect(page.locator('#github-token-add')).toBeHidden()
519549
await expect(page.locator('#github-token-delete')).toBeVisible()
550+
await ensureOpenPrDrawerOpen(page)
520551
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
521552
})
522553

554+
test('Open PR drawer confirms and submits component/styles filepaths', async ({
555+
page,
556+
}) => {
557+
let createdRefBody: CreateRefRequestBody | null = null
558+
const upsertRequests: Array<{ path: string; body: Record<string, unknown> }> = []
559+
let pullRequestBody: PullRequestCreateBody | null = null
560+
561+
await page.route('https://api.github.com/user/repos**', async route => {
562+
await route.fulfill({
563+
status: 200,
564+
contentType: 'application/json',
565+
body: JSON.stringify([
566+
{
567+
id: 11,
568+
owner: { login: 'knightedcodemonkey' },
569+
name: 'develop',
570+
full_name: 'knightedcodemonkey/develop',
571+
default_branch: 'main',
572+
permissions: { push: true },
573+
},
574+
]),
575+
})
576+
})
577+
578+
await page.route(
579+
'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
580+
async route => {
581+
await route.fulfill({
582+
status: 200,
583+
contentType: 'application/json',
584+
body: JSON.stringify({
585+
ref: 'refs/heads/main',
586+
object: { type: 'commit', sha: 'abc123mainsha' },
587+
}),
588+
})
589+
},
590+
)
591+
592+
await page.route(
593+
'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
594+
async route => {
595+
createdRefBody = route.request().postDataJSON() as CreateRefRequestBody
596+
await route.fulfill({
597+
status: 201,
598+
contentType: 'application/json',
599+
body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }),
600+
})
601+
},
602+
)
603+
604+
await page.route(
605+
'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
606+
async route => {
607+
const request = route.request()
608+
const method = request.method()
609+
const url = request.url()
610+
const path = new URL(url).pathname.split('/contents/')[1] ?? ''
611+
612+
if (method === 'GET') {
613+
await route.fulfill({
614+
status: 404,
615+
contentType: 'application/json',
616+
body: JSON.stringify({ message: 'Not Found' }),
617+
})
618+
return
619+
}
620+
621+
const body = request.postDataJSON() as Record<string, unknown>
622+
upsertRequests.push({ path: decodeURIComponent(path), body })
623+
await route.fulfill({
624+
status: 201,
625+
contentType: 'application/json',
626+
body: JSON.stringify({ commit: { sha: 'commit-sha' } }),
627+
})
628+
},
629+
)
630+
631+
await page.route(
632+
'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
633+
async route => {
634+
pullRequestBody = route.request().postDataJSON() as PullRequestCreateBody
635+
await route.fulfill({
636+
status: 201,
637+
contentType: 'application/json',
638+
body: JSON.stringify({
639+
number: 42,
640+
html_url: 'https://github.com/knightedcodemonkey/develop/pull/42',
641+
}),
642+
})
643+
},
644+
)
645+
646+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
647+
await connectByotWithSingleRepo(page)
648+
await ensureOpenPrDrawerOpen(page)
649+
650+
await page.locator('#github-pr-head-branch').fill('develop/open-pr-test')
651+
await page.locator('#github-pr-component-path').fill('examples/component/App.tsx')
652+
await page.locator('#github-pr-styles-path').fill('examples/styles/app.css')
653+
await page.locator('#github-pr-title').fill('Apply editor updates from develop')
654+
await page
655+
.locator('#github-pr-body')
656+
.fill('Generated from editor content in @knighted/develop.')
657+
658+
await page.locator('#github-pr-submit').click()
659+
660+
const dialog = page.locator('#clear-confirm-dialog')
661+
await expect(dialog).toHaveAttribute('open', '')
662+
await expect(page.locator('#clear-confirm-title')).toHaveText(
663+
'Open pull request with editor content?',
664+
)
665+
await expect(page.locator('#clear-confirm-copy')).toContainText(
666+
'Component file path: examples/component/App.tsx',
667+
)
668+
await expect(page.locator('#clear-confirm-copy')).toContainText(
669+
'Styles file path: examples/styles/app.css',
670+
)
671+
672+
await dialog.getByRole('button', { name: 'Open PR' }).click()
673+
674+
await expect(page.locator('#github-pr-status')).toContainText(
675+
'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/42',
676+
)
677+
678+
const createdRefPayload = createdRefBody as CreateRefRequestBody | null
679+
const pullRequestPayload = pullRequestBody as PullRequestCreateBody | null
680+
681+
expect(createdRefPayload?.ref).toBe('refs/heads/develop/open-pr-test')
682+
expect(createdRefPayload?.sha).toBe('abc123mainsha')
683+
684+
expect(upsertRequests).toHaveLength(2)
685+
expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx')
686+
expect(upsertRequests[1]?.path).toBe('examples/styles/app.css')
687+
expect(pullRequestPayload?.head).toBe('develop/open-pr-test')
688+
expect(pullRequestPayload?.base).toBe('main')
689+
})
690+
691+
test('Open PR drawer validates unsafe filepaths', async ({ page }) => {
692+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
693+
await connectByotWithSingleRepo(page)
694+
await ensureOpenPrDrawerOpen(page)
695+
696+
await page.locator('#github-pr-component-path').fill('../outside/App.tsx')
697+
await page.locator('#github-pr-submit').click()
698+
699+
await expect(page.locator('#github-pr-status')).toContainText(
700+
'Component path: File path cannot include parent directory traversal.',
701+
)
702+
await expect(page.locator('#clear-confirm-dialog')).not.toHaveAttribute('open', '')
703+
})
704+
523705
test('renders default playground preview', async ({ page }) => {
524706
await waitForInitialRender(page)
525707

0 commit comments

Comments
 (0)