@@ -1054,4 +1054,210 @@ describe("interact tool", () => {
10541054 await server . close ( ) ;
10551055 } ) ;
10561056 } ) ;
1057+
1058+ describe ( "save_as" , ( ) => {
1059+ // save_as is the FIRST interact action with a full request/reply unit
1060+ // test (get_text/get_screenshot use the same plumbing but are e2e-only).
1061+ // The roundtrip tests need: writable scope, kick off interact WITHOUT
1062+ // awaiting (it blocks until the view replies), poll → submit → await.
1063+
1064+ let tmpDir : string ;
1065+ let savedDirs : Set < string > ;
1066+
1067+ beforeEach ( ( ) => {
1068+ tmpDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "pdf-saveas-" ) ) ;
1069+ savedDirs = new Set ( allowedLocalDirs ) ;
1070+ allowedLocalDirs . add ( tmpDir ) ; // make tmpDir a directory root → writable
1071+ } ) ;
1072+
1073+ afterEach ( ( ) => {
1074+ allowedLocalDirs . clear ( ) ;
1075+ for ( const x of savedDirs ) allowedLocalDirs . add ( x ) ;
1076+ fs . rmSync ( tmpDir , { recursive : true , force : true } ) ;
1077+ } ) ;
1078+
1079+ it ( "rejects without `path`" , async ( ) => {
1080+ const { server, client } = await connect ( ) ;
1081+ const r = await client . callTool ( {
1082+ name : "interact" ,
1083+ arguments : { viewUUID : "saveas-nopath" , action : "save_as" } ,
1084+ } ) ;
1085+ expect ( r . isError ) . toBe ( true ) ;
1086+ expect ( firstText ( r ) ) . toContain ( "save_as" ) ;
1087+ expect ( firstText ( r ) ) . toContain ( "path" ) ;
1088+ await client . close ( ) ;
1089+ await server . close ( ) ;
1090+ } ) ;
1091+
1092+ it ( "rejects non-absolute path" , async ( ) => {
1093+ const { server, client } = await connect ( ) ;
1094+ const r = await client . callTool ( {
1095+ name : "interact" ,
1096+ arguments : {
1097+ viewUUID : "saveas-rel" ,
1098+ action : "save_as" ,
1099+ path : "relative.pdf" ,
1100+ } ,
1101+ } ) ;
1102+ expect ( r . isError ) . toBe ( true ) ;
1103+ expect ( firstText ( r ) ) . toContain ( "absolute" ) ;
1104+ await client . close ( ) ;
1105+ await server . close ( ) ;
1106+ } ) ;
1107+
1108+ it ( "rejects non-writable path" , async ( ) => {
1109+ const { server, client } = await connect ( ) ;
1110+ // Path outside any directory root → not writable. Validation is sync,
1111+ // so nothing is enqueued and the queue stays empty.
1112+ const r = await client . callTool ( {
1113+ name : "interact" ,
1114+ arguments : {
1115+ viewUUID : "saveas-nowrite" ,
1116+ action : "save_as" ,
1117+ path : "/somewhere/else/out.pdf" ,
1118+ } ,
1119+ } ) ;
1120+ expect ( r . isError ) . toBe ( true ) ;
1121+ expect ( firstText ( r ) ) . toContain ( "not under a mounted directory root" ) ;
1122+ await client . close ( ) ;
1123+ await server . close ( ) ;
1124+ } ) ;
1125+
1126+ it ( "rejects existing file when overwrite is false (default)" , async ( ) => {
1127+ const { server, client } = await connect ( ) ;
1128+ const target = path . join ( tmpDir , "exists.pdf" ) ;
1129+ fs . writeFileSync ( target , "old contents" ) ;
1130+
1131+ const r = await client . callTool ( {
1132+ name : "interact" ,
1133+ arguments : {
1134+ viewUUID : "saveas-exists" ,
1135+ action : "save_as" ,
1136+ path : target ,
1137+ } ,
1138+ } ) ;
1139+ expect ( r . isError ) . toBe ( true ) ;
1140+ expect ( firstText ( r ) ) . toContain ( "already exists" ) ;
1141+ expect ( firstText ( r ) ) . toContain ( "overwrite: true" ) ;
1142+ // Existence check is sync — nothing enqueued, file untouched.
1143+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( "old contents" ) ;
1144+ await client . close ( ) ;
1145+ await server . close ( ) ;
1146+ } ) ;
1147+
1148+ it ( "full roundtrip: enqueue → poll → submit → file written" , async ( ) => {
1149+ const { server, client } = await connect ( ) ;
1150+ const uuid = "saveas-roundtrip" ;
1151+ const target = path . join ( tmpDir , "out.pdf" ) ;
1152+ const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF" ;
1153+
1154+ // interact blocks in waitForSaveData until submit_save_data resolves it
1155+ const interactPromise = client . callTool ( {
1156+ name : "interact" ,
1157+ arguments : { viewUUID : uuid , action : "save_as" , path : target } ,
1158+ } ) ;
1159+
1160+ // Viewer polls → receives the save_as command with a requestId
1161+ const cmds = await poll ( client , uuid ) ;
1162+ expect ( cmds ) . toHaveLength ( 1 ) ;
1163+ expect ( cmds [ 0 ] . type ) . toBe ( "save_as" ) ;
1164+ const requestId = cmds [ 0 ] . requestId as string ;
1165+ expect ( typeof requestId ) . toBe ( "string" ) ;
1166+
1167+ // Viewer submits bytes
1168+ const submit = await client . callTool ( {
1169+ name : "submit_save_data" ,
1170+ arguments : {
1171+ requestId,
1172+ data : Buffer . from ( pdfBytes ) . toString ( "base64" ) ,
1173+ } ,
1174+ } ) ;
1175+ expect ( submit . isError ) . toBeFalsy ( ) ;
1176+
1177+ // interact now unblocks with success
1178+ const r = await interactPromise ;
1179+ expect ( r . isError ) . toBeFalsy ( ) ;
1180+ expect ( firstText ( r ) ) . toContain ( "Saved" ) ;
1181+ expect ( firstText ( r ) ) . toContain ( target ) ;
1182+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( pdfBytes ) ;
1183+
1184+ await client . close ( ) ;
1185+ await server . close ( ) ;
1186+ } ) ;
1187+
1188+ it ( "overwrite: true replaces an existing file" , async ( ) => {
1189+ const { server, client } = await connect ( ) ;
1190+ const uuid = "saveas-overwrite" ;
1191+ const target = path . join ( tmpDir , "replace.pdf" ) ;
1192+ fs . writeFileSync ( target , "old contents" ) ;
1193+
1194+ const interactPromise = client . callTool ( {
1195+ name : "interact" ,
1196+ arguments : {
1197+ viewUUID : uuid ,
1198+ action : "save_as" ,
1199+ path : target ,
1200+ overwrite : true ,
1201+ } ,
1202+ } ) ;
1203+
1204+ const cmds = await poll ( client , uuid ) ;
1205+ const requestId = cmds [ 0 ] . requestId as string ;
1206+ await client . callTool ( {
1207+ name : "submit_save_data" ,
1208+ arguments : {
1209+ requestId,
1210+ data : Buffer . from ( "%PDF-1.4\nnew" ) . toString ( "base64" ) ,
1211+ } ,
1212+ } ) ;
1213+
1214+ const r = await interactPromise ;
1215+ expect ( r . isError ) . toBeFalsy ( ) ;
1216+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( "%PDF-1.4\nnew" ) ;
1217+
1218+ await client . close ( ) ;
1219+ await server . close ( ) ;
1220+ } ) ;
1221+
1222+ it ( "propagates viewer-reported errors to the model" , async ( ) => {
1223+ const { server, client } = await connect ( ) ;
1224+ const uuid = "saveas-viewerr" ;
1225+ const target = path . join ( tmpDir , "wontwrite.pdf" ) ;
1226+
1227+ const interactPromise = client . callTool ( {
1228+ name : "interact" ,
1229+ arguments : { viewUUID : uuid , action : "save_as" , path : target } ,
1230+ } ) ;
1231+
1232+ const cmds = await poll ( client , uuid ) ;
1233+ // Viewer hit an error building bytes → reports it instead of timing out
1234+ await client . callTool ( {
1235+ name : "submit_save_data" ,
1236+ arguments : {
1237+ requestId : cmds [ 0 ] . requestId as string ,
1238+ error : "pdf-lib choked on a comb field" ,
1239+ } ,
1240+ } ) ;
1241+
1242+ const r = await interactPromise ;
1243+ expect ( r . isError ) . toBe ( true ) ;
1244+ expect ( firstText ( r ) ) . toContain ( "pdf-lib choked on a comb field" ) ;
1245+ expect ( fs . existsSync ( target ) ) . toBe ( false ) ;
1246+
1247+ await client . close ( ) ;
1248+ await server . close ( ) ;
1249+ } ) ;
1250+
1251+ it ( "submit_save_data with unknown requestId returns isError" , async ( ) => {
1252+ const { server, client } = await connect ( ) ;
1253+ const r = await client . callTool ( {
1254+ name : "submit_save_data" ,
1255+ arguments : { requestId : "never-created" , data : "AAAA" } ,
1256+ } ) ;
1257+ expect ( r . isError ) . toBe ( true ) ;
1258+ expect ( firstText ( r ) ) . toContain ( "No pending request" ) ;
1259+ await client . close ( ) ;
1260+ await server . close ( ) ;
1261+ } ) ;
1262+ } ) ;
10571263} ) ;
0 commit comments