@@ -1029,4 +1029,249 @@ describe('upgradeToCollaboration', () => {
10291029 // After upgrade: callback cleared
10301030 expect ( instance . _upgradeVisualReadyCallback ) . toBeNull ( ) ;
10311031 } ) ;
1032+
1033+ // -----------------------------------------------------------------------
1034+ // Rollback regression: leaked runtime (Fix 1)
1035+ // -----------------------------------------------------------------------
1036+
1037+ it ( 'stops the failed collaborative runtime before starting rollback (mount throws)' , async ( ) => {
1038+ const harness = createAppHarness ( ) ;
1039+ const instance = new SuperDoc ( {
1040+ selector : '#host' ,
1041+ documents : [ { id : 'doc-1' , type : DOCX , data : new Blob ( ) } ] ,
1042+ modules : { comments : { } } ,
1043+ colors : [ ] ,
1044+ onException : vi . fn ( ) ,
1045+ } ) ;
1046+ await flushMicrotasks ( ) ;
1047+ instance . readyEditors = 1 ;
1048+
1049+ const collabUnmount = vi . fn ( ) ;
1050+ let callCount = 0 ;
1051+
1052+ createVueAppMock . mockImplementation ( ( ) => {
1053+ callCount ++ ;
1054+ if ( callCount === 1 ) {
1055+ // Collaborative runtime: app created but mount throws
1056+ return {
1057+ app : {
1058+ mount : vi . fn ( ( ) => {
1059+ throw new Error ( 'Collaborative mount failed' ) ;
1060+ } ) ,
1061+ unmount : collabUnmount ,
1062+ config : { globalProperties : { } } ,
1063+ } ,
1064+ pinia : { } ,
1065+ superdocStore : harness . superdocStore ,
1066+ commentsStore : harness . commentsStore ,
1067+ highContrastModeStore : { } ,
1068+ } ;
1069+ }
1070+ // Rollback runtime: succeeds
1071+ return {
1072+ app : {
1073+ mount : vi . fn ( ( wrapper ) => {
1074+ const el = document . createElement ( 'div' ) ;
1075+ el . className = 'superdoc' ;
1076+ wrapper . appendChild ( el ) ;
1077+ setTimeout ( ( ) => {
1078+ if ( instance . _upgradeVisualReadyCallback ) {
1079+ instance . _upgradeVisualReadyCallback ( ) ;
1080+ }
1081+ } , 0 ) ;
1082+ } ) ,
1083+ unmount : vi . fn ( ) ,
1084+ config : { globalProperties : { } } ,
1085+ } ,
1086+ pinia : { } ,
1087+ superdocStore : harness . superdocStore ,
1088+ commentsStore : harness . commentsStore ,
1089+ highContrastModeStore : { } ,
1090+ } ;
1091+ } ) ;
1092+
1093+ await expect (
1094+ instance . upgradeToCollaboration ( {
1095+ ydoc : createMockYDoc ( ) ,
1096+ provider : createMockProvider ( ) ,
1097+ } ) ,
1098+ ) . rejects . toThrow ( 'Collaborative mount failed' ) ;
1099+
1100+ // The collaborative app must be unmounted via #stopRuntime() before rollback
1101+ expect ( collabUnmount ) . toHaveBeenCalled ( ) ;
1102+ } ) ;
1103+
1104+ it ( 'stops a timed-out collaborative runtime before starting rollback' , async ( ) => {
1105+ vi . useFakeTimers ( ) ;
1106+ try {
1107+ const harness = createAppHarness ( ) ;
1108+ const instance = new SuperDoc ( {
1109+ selector : '#host' ,
1110+ documents : [ { id : 'doc-1' , type : DOCX , data : new Blob ( ) } ] ,
1111+ modules : { comments : { } } ,
1112+ colors : [ ] ,
1113+ onException : vi . fn ( ) ,
1114+ } ) ;
1115+ await vi . advanceTimersByTimeAsync ( 0 ) ;
1116+ instance . readyEditors = 1 ;
1117+
1118+ const collabUnmount = vi . fn ( ) ;
1119+ let callCount = 0 ;
1120+
1121+ createVueAppMock . mockImplementation ( ( ) => {
1122+ callCount ++ ;
1123+ if ( callCount === 1 ) {
1124+ // Collaborative runtime: mounts but never fires visual-ready
1125+ return {
1126+ app : {
1127+ mount : vi . fn ( ( wrapper ) => {
1128+ const el = document . createElement ( 'div' ) ;
1129+ el . className = 'superdoc' ;
1130+ wrapper . appendChild ( el ) ;
1131+ // visual-ready callback deliberately NOT called
1132+ } ) ,
1133+ unmount : collabUnmount ,
1134+ config : { globalProperties : { } } ,
1135+ } ,
1136+ pinia : { } ,
1137+ superdocStore : harness . superdocStore ,
1138+ commentsStore : harness . commentsStore ,
1139+ highContrastModeStore : { } ,
1140+ } ;
1141+ }
1142+ // Rollback runtime: fires visual-ready immediately
1143+ return {
1144+ app : {
1145+ mount : vi . fn ( ( wrapper ) => {
1146+ const el = document . createElement ( 'div' ) ;
1147+ el . className = 'superdoc' ;
1148+ wrapper . appendChild ( el ) ;
1149+ // Fire visual-ready synchronously to avoid timer complications
1150+ if ( instance . _upgradeVisualReadyCallback ) {
1151+ instance . _upgradeVisualReadyCallback ( ) ;
1152+ }
1153+ } ) ,
1154+ unmount : vi . fn ( ) ,
1155+ config : { globalProperties : { } } ,
1156+ } ,
1157+ pinia : { } ,
1158+ superdocStore : harness . superdocStore ,
1159+ commentsStore : harness . commentsStore ,
1160+ highContrastModeStore : { } ,
1161+ } ;
1162+ } ) ;
1163+
1164+ const upgradePromise = instance . upgradeToCollaboration ( {
1165+ ydoc : createMockYDoc ( ) ,
1166+ provider : createMockProvider ( ) ,
1167+ } ) ;
1168+
1169+ // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
1170+ const resultPromise = expect ( upgradePromise ) . rejects . toThrow ( 'visually ready within 30 s' ) ;
1171+
1172+ // Advance past the 30s collaborative timeout
1173+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
1174+
1175+ await resultPromise ;
1176+
1177+ // The timed-out collaborative app must be unmounted before rollback
1178+ expect ( collabUnmount ) . toHaveBeenCalled ( ) ;
1179+ } finally {
1180+ vi . useRealTimers ( ) ;
1181+ }
1182+ } ) ;
1183+
1184+ // -----------------------------------------------------------------------
1185+ // Rollback regression: incomplete state restoration (Fix 2)
1186+ // -----------------------------------------------------------------------
1187+
1188+ it ( 'restores convertedXml and mediaFiles on the rollback editor' , async ( ) => {
1189+ const harness = createAppHarness ( ) ;
1190+
1191+ // Simulate pre-upgrade edits that modified parts and media
1192+ harness . mockEditor . converter . convertedXml = {
1193+ 'word/styles.xml' : { tag : 'w:styles' , children : [ { tag : 'w:style' , attrs : { id : 'custom' } } ] } ,
1194+ 'word/numbering.xml' : { tag : 'w:numbering' , children : [ { tag : 'w:abstractNum' } ] } ,
1195+ } ;
1196+ harness . mockEditor . options . mediaFiles = {
1197+ 'word/media/image1.png' : 'base64-data-here' ,
1198+ } ;
1199+
1200+ const instance = new SuperDoc ( {
1201+ selector : '#host' ,
1202+ documents : [ { id : 'doc-1' , type : DOCX , data : new Blob ( ) } ] ,
1203+ modules : { comments : { } } ,
1204+ colors : [ ] ,
1205+ onException : vi . fn ( ) ,
1206+ } ) ;
1207+ await flushMicrotasks ( ) ;
1208+ instance . readyEditors = 1 ;
1209+
1210+ // The rollback editor starts with empty/reimported state
1211+ const rollbackEditor = {
1212+ converter : { convertedXml : { } } ,
1213+ options : { mediaFiles : { } , fonts : { } } ,
1214+ state : harness . mockEditor . state ,
1215+ getJSON : harness . mockEditor . getJSON ,
1216+ } ;
1217+
1218+ let callCount = 0 ;
1219+ createVueAppMock . mockImplementation ( ( ) => {
1220+ callCount ++ ;
1221+ if ( callCount === 1 ) {
1222+ // Collaborative: fails during createSuperdocVueApp
1223+ throw new Error ( 'Simulated collab failure' ) ;
1224+ }
1225+ // Rollback: succeeds, returns a store with the rollback editor
1226+ return {
1227+ app : {
1228+ mount : vi . fn ( ( wrapper ) => {
1229+ const el = document . createElement ( 'div' ) ;
1230+ el . className = 'superdoc' ;
1231+ wrapper . appendChild ( el ) ;
1232+ setTimeout ( ( ) => {
1233+ if ( instance . _upgradeVisualReadyCallback ) {
1234+ instance . _upgradeVisualReadyCallback ( ) ;
1235+ }
1236+ } , 0 ) ;
1237+ } ) ,
1238+ unmount : vi . fn ( ) ,
1239+ config : { globalProperties : { } } ,
1240+ } ,
1241+ pinia : { } ,
1242+ superdocStore : {
1243+ documents : [
1244+ {
1245+ id : 'doc-1' ,
1246+ type : DOCX ,
1247+ getEditor : ( ) => rollbackEditor ,
1248+ setEditor : vi . fn ( ) ,
1249+ } ,
1250+ ] ,
1251+ init : vi . fn ( ) ,
1252+ reset : vi . fn ( ) ,
1253+ setExceptionHandler : vi . fn ( ) ,
1254+ activeZoom : 100 ,
1255+ } ,
1256+ commentsStore : harness . commentsStore ,
1257+ highContrastModeStore : { } ,
1258+ } ;
1259+ } ) ;
1260+
1261+ await expect (
1262+ instance . upgradeToCollaboration ( {
1263+ ydoc : createMockYDoc ( ) ,
1264+ provider : createMockProvider ( ) ,
1265+ } ) ,
1266+ ) . rejects . toThrow ( 'Simulated collab failure' ) ;
1267+
1268+ // The rollback editor should have the pre-upgrade parts and media restored
1269+ expect ( rollbackEditor . converter . convertedXml ) . toEqual ( {
1270+ 'word/styles.xml' : { tag : 'w:styles' , children : [ { tag : 'w:style' , attrs : { id : 'custom' } } ] } ,
1271+ 'word/numbering.xml' : { tag : 'w:numbering' , children : [ { tag : 'w:abstractNum' } ] } ,
1272+ } ) ;
1273+ expect ( rollbackEditor . options . mediaFiles ) . toEqual ( {
1274+ 'word/media/image1.png' : 'base64-data-here' ,
1275+ } ) ;
1276+ } ) ;
10321277} ) ;
0 commit comments