@@ -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+
2030const 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
2635const 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+
127148const 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
152172const 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
192214test ( '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
204227test ( '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
478501test ( '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+
523705test ( 'renders default playground preview' , async ( { page } ) => {
524706 await waitForInitialRender ( page )
525707
0 commit comments