@@ -433,6 +433,290 @@ test('Renaming a synced module tab keeps plain tab label and includes renamed pa
433433 } )
434434} )
435435
436+ test ( 'Removing a synced module tab includes a delete entry when pushing to an active PR' , async ( {
437+ page,
438+ } ) => {
439+ const treeRequests : Array < Record < string , unknown > > = [ ]
440+
441+ await page . route ( 'https://api.github.com/user/repos**' , async route => {
442+ await route . fulfill ( {
443+ status : 200 ,
444+ contentType : 'application/json' ,
445+ body : JSON . stringify ( [
446+ {
447+ id : 11 ,
448+ owner : { login : 'knightedcodemonkey' } ,
449+ name : 'develop' ,
450+ full_name : 'knightedcodemonkey/develop' ,
451+ default_branch : 'main' ,
452+ permissions : { push : true } ,
453+ } ,
454+ ] ) ,
455+ } )
456+ } )
457+
458+ await mockRepositoryBranches ( page , {
459+ 'knightedcodemonkey/develop' : [ 'main' , 'release' , 'develop/open-pr-test' ] ,
460+ } )
461+
462+ await page . route (
463+ 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2' ,
464+ async route => {
465+ await route . fulfill ( {
466+ status : 200 ,
467+ contentType : 'application/json' ,
468+ body : JSON . stringify ( {
469+ number : 2 ,
470+ state : 'open' ,
471+ title : 'Existing PR context from storage' ,
472+ html_url : 'https://github.com/knightedcodemonkey/develop/pull/2' ,
473+ head : { ref : 'develop/open-pr-test' } ,
474+ base : { ref : 'main' } ,
475+ } ) ,
476+ } )
477+ } ,
478+ )
479+
480+ await page . route (
481+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**' ,
482+ async route => {
483+ await route . fulfill ( {
484+ status : 200 ,
485+ contentType : 'application/json' ,
486+ body : JSON . stringify ( {
487+ ref : 'refs/heads/develop/open-pr-test' ,
488+ object : { type : 'commit' , sha : 'existing-head-sha' } ,
489+ } ) ,
490+ } )
491+ } ,
492+ )
493+
494+ await page . route (
495+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha' ,
496+ async route => {
497+ await route . fulfill ( {
498+ status : 200 ,
499+ contentType : 'application/json' ,
500+ body : JSON . stringify ( {
501+ sha : 'existing-head-sha' ,
502+ tree : { sha : 'base-tree-sha' } ,
503+ } ) ,
504+ } )
505+ } ,
506+ )
507+
508+ await page . route (
509+ 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**' ,
510+ async route => {
511+ const url = new URL ( route . request ( ) . url ( ) )
512+ const path = decodeURIComponent ( url . pathname . split ( '/contents/' ) [ 1 ] ?? '' ) . trim ( )
513+ const responseByPath : Record < string , { status : number ; body : string } > = {
514+ 'src/components/boop.tsx' : {
515+ status : 200 ,
516+ body : JSON . stringify ( { sha : 'boop-existing-sha' } ) ,
517+ } ,
518+ }
519+ const response = responseByPath [ path ] ?? {
520+ status : 404 ,
521+ body : JSON . stringify ( { message : 'Not Found' } ) ,
522+ }
523+
524+ await route . fulfill ( {
525+ status : response . status ,
526+ contentType : 'application/json' ,
527+ body : response . body ,
528+ } )
529+ } ,
530+ )
531+
532+ await page . route (
533+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees' ,
534+ async route => {
535+ treeRequests . push ( route . request ( ) . postDataJSON ( ) as Record < string , unknown > )
536+
537+ await route . fulfill ( {
538+ status : 201 ,
539+ contentType : 'application/json' ,
540+ body : JSON . stringify ( { sha : 'remove-tree-sha' } ) ,
541+ } )
542+ } ,
543+ )
544+
545+ await page . route (
546+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits' ,
547+ async route => {
548+ await route . fulfill ( {
549+ status : 201 ,
550+ contentType : 'application/json' ,
551+ body : JSON . stringify ( { sha : 'remove-commit-sha' } ) ,
552+ } )
553+ } ,
554+ )
555+
556+ await page . route (
557+ 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**' ,
558+ async route => {
559+ await route . fulfill ( {
560+ status : 200 ,
561+ contentType : 'application/json' ,
562+ body : JSON . stringify ( {
563+ ref : 'refs/heads/develop/open-pr-test' ,
564+ object : { type : 'commit' , sha : 'remove-commit-sha' } ,
565+ } ) ,
566+ } )
567+ } ,
568+ )
569+
570+ await waitForAppReady ( page , `${ appEntryPath } ` )
571+
572+ const now = Date . now ( )
573+ await seedLocalWorkspaceContexts ( page , [
574+ {
575+ id : buildWorkspaceRecordId ( {
576+ repositoryFullName : 'knightedcodemonkey/develop' ,
577+ headBranch : 'develop/open-pr-test' ,
578+ } ) ,
579+ repo : 'knightedcodemonkey/develop' ,
580+ base : 'main' ,
581+ head : 'develop/open-pr-test' ,
582+ prTitle : 'Existing PR context from storage' ,
583+ prNumber : 2 ,
584+ prContextState : 'active' ,
585+ renderMode : 'react' ,
586+ tabs : [
587+ {
588+ id : 'component' ,
589+ name : 'App.tsx' ,
590+ path : 'src/components/App.tsx' ,
591+ language : 'javascript-jsx' ,
592+ role : 'entry' ,
593+ isActive : true ,
594+ content : 'export const App = () => <main>Hello from Knighted</main>' ,
595+ targetPrFilePath : 'src/components/App.tsx' ,
596+ syncedContent : 'export const App = () => <main>Hello from Knighted</main>' ,
597+ syncedAt : now ,
598+ isDirty : false ,
599+ } ,
600+ {
601+ id : 'styles' ,
602+ name : 'app.css' ,
603+ path : 'src/styles/app.css' ,
604+ language : 'css' ,
605+ role : 'module' ,
606+ isActive : false ,
607+ content : 'main { color: #111; }' ,
608+ targetPrFilePath : 'src/styles/app.css' ,
609+ syncedContent : 'main { color: #111; }' ,
610+ syncedAt : now ,
611+ isDirty : false ,
612+ } ,
613+ {
614+ id : 'boop' ,
615+ name : 'boop.tsx' ,
616+ path : 'src/components/boop.tsx' ,
617+ language : 'javascript-jsx' ,
618+ role : 'module' ,
619+ isActive : false ,
620+ content : 'export const Boop = () => <p>boop</p>' ,
621+ targetPrFilePath : 'src/components/boop.tsx' ,
622+ syncedContent : 'export const Boop = () => <p>boop</p>' ,
623+ syncedAt : now ,
624+ isDirty : false ,
625+ } ,
626+ ] ,
627+ activeTabId : 'component' ,
628+ createdAt : now ,
629+ lastModified : now ,
630+ } ,
631+ ] )
632+
633+ await connectByotWithSingleRepo ( page )
634+ await openMostRecentStoredWorkspaceContext ( page )
635+
636+ await page . getByRole ( 'button' , { name : 'Remove tab boop.tsx' } ) . click ( )
637+ const removeDialog = page . locator ( '#clear-confirm-dialog' )
638+ await expect ( removeDialog ) . toBeVisible ( )
639+ await removeDialog . locator ( 'button[value="confirm"]' ) . evaluate ( element => {
640+ if ( element instanceof HTMLButtonElement ) {
641+ element . click ( )
642+ }
643+ } )
644+ await expect ( page . getByRole ( 'button' , { name : 'Open tab boop.tsx' } ) ) . toHaveCount ( 0 )
645+
646+ await setComponentEditorSource (
647+ page ,
648+ 'export const App = () => <main>Updated entry after removal</main>' ,
649+ )
650+
651+ await ensureOpenPrDrawerOpen ( page )
652+ const pushCommitButton = page
653+ . locator ( '#github-pr-drawer' )
654+ . getByRole ( 'button' , { name : 'Push commit' , exact : true } )
655+ await expect ( pushCommitButton ) . toBeEnabled ( )
656+ await pushCommitButton . evaluate ( element => {
657+ if ( element instanceof HTMLButtonElement ) {
658+ element . click ( )
659+ }
660+ } )
661+
662+ const pushDialog = page . locator ( '#clear-confirm-dialog' )
663+ await expect ( pushDialog ) . toBeVisible ( )
664+ await expect ( pushDialog . getByText ( 'Files to commit:' , { exact : true } ) ) . toBeVisible ( )
665+ await expect (
666+ pushDialog . getByText ( / s r c \/ c o m p o n e n t s \/ b o o p \. t s x .* \( d e l e t e \) / , { exact : false } ) ,
667+ ) . toBeVisible ( )
668+
669+ await pushDialog . locator ( 'button[value="confirm"]' ) . evaluate ( element => {
670+ if ( element instanceof HTMLButtonElement ) {
671+ element . click ( )
672+ }
673+ } )
674+
675+ await expect (
676+ page . getByRole ( 'status' , { name : 'Open pull request status' , includeHidden : true } ) ,
677+ ) . toContainText ( 'Commit pushed to develop/open-pr-test' )
678+
679+ await expect
680+ . poll ( async ( ) => {
681+ const workspaceRecord = await getWorkspaceTabsRecord ( page , {
682+ headBranch : 'develop/open-pr-test' ,
683+ } )
684+ const tabs = Array . isArray ( workspaceRecord ?. tabs )
685+ ? ( workspaceRecord . tabs as Array < Record < string , unknown > > )
686+ : [ ]
687+
688+ return tabs . some ( tab => {
689+ const path = typeof tab ?. path === 'string' ? tab . path . trim ( ) : ''
690+ return path === 'src/components/boop.tsx'
691+ } )
692+ } )
693+ . toBe ( false )
694+
695+ expect ( treeRequests ) . toHaveLength ( 1 )
696+ const treePayload = treeRequests [ 0 ] ?. tree as Array < Record < string , unknown > >
697+ const updatedEntryBlob = treePayload ?. find (
698+ file => file . path === 'src/components/App.tsx' ,
699+ )
700+ const deletedBlob = treePayload ?. find ( file => file . path === 'src/components/boop.tsx' )
701+
702+ expect ( updatedEntryBlob ) . toMatchObject ( {
703+ path : 'src/components/App.tsx' ,
704+ mode : '100644' ,
705+ type : 'blob' ,
706+ } )
707+ expect ( typeof updatedEntryBlob ?. content ) . toBe ( 'string' )
708+ expect (
709+ ( updatedEntryBlob ?. content as string ) . includes ( 'Updated entry after removal' ) ,
710+ ) . toBe ( true )
711+
712+ expect ( deletedBlob ) . toEqual ( {
713+ path : 'src/components/boop.tsx' ,
714+ mode : '100644' ,
715+ type : 'blob' ,
716+ sha : null ,
717+ } )
718+ } )
719+
436720test ( 'Push commit prunes stale delete entries before Git tree creation' , async ( {
437721 page,
438722} ) => {
0 commit comments