@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, it, suite } from 'mocha'
33import { ensureFileSync , remove } from 'fs-extra'
44import { expect } from 'chai'
55import { SinonStub , restore , spy , stub } from 'sinon'
6+ import * as Fetch from 'node-fetch'
67import {
78 MessageItem ,
89 QuickPickItem ,
@@ -43,12 +44,15 @@ import { MIN_CLI_VERSION } from '../../../cli/dvc/contract'
4344import { run } from '../../../setup/runner'
4445import * as Python from '../../../extensions/python'
4546import { ContextKey } from '../../../vscode/context'
47+ import * as ExternalUtil from '../../../vscode/external'
4648import { Setup } from '../../../setup'
47- import { SetupSection } from '../../../setup/webview/contract'
49+ import { STUDIO_URL , SetupSection } from '../../../setup/webview/contract'
4850import { getFirstWorkspaceFolder } from '../../../vscode/workspaceFolders'
4951import { Response } from '../../../vscode/response'
5052import { DvcConfig } from '../../../cli/dvc/config'
5153import * as QuickPickUtil from '../../../setup/quickPick'
54+ import { EventName } from '../../../telemetry/constants'
55+ import { Modal } from '../../../vscode/modal'
5256
5357suite ( 'Setup Test Suite' , ( ) => {
5458 const disposable = Disposable . fn ( )
@@ -850,49 +854,137 @@ suite('Setup Test Suite', () => {
850854 ) . to . be . calledWithExactly ( 'setContext' , 'dvc.cli.incompatible' , false )
851855 } )
852856
853- it ( 'should handle a message from the webview to open Studio ' , async ( ) => {
854- const { mockOpenExternal , setup , urlOpenedEvent } = buildSetup ( {
857+ it ( 'should handle a message from the webview to request a token from studio ' , async ( ) => {
858+ const { setup , mockFetch , studio } = buildSetup ( {
855859 disposer : disposable
856860 } )
857861
862+ const mockConfig = stub ( DvcConfig . prototype , 'config' )
863+ mockConfig . resolves ( '' )
858864 const webview = await setup . showWebview ( )
859865 await webview . isReady ( )
866+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
867+ stub ( Setup . prototype as any , 'getCliCompatible' ) . returns ( true )
860868
861869 const mockMessageReceived = getMessageReceivedEmitter ( webview )
870+ const mockSendTelemetryEvent = stub ( Telemetry , 'sendTelemetryEvent' )
871+ const mockGetCallbackUrl = stub ( ExternalUtil , 'getCallBackUrl' )
872+ const mockOpenUrl = stub ( ExternalUtil , 'openUrl' )
873+ const mockWaitForUriRes = stub ( ExternalUtil , 'waitForUriResponse' )
874+ const mockStudioRes = {
875+ device_code : 'Yi-NPd9ggvNUDBcam5bP8iivbtLhnqVgM_lSSbilqNw' ,
876+ token_uri : 'https://studio.iterative.ai/api/device-login/token' ,
877+ user_code : '40DWMKNA' ,
878+ verification_uri : 'https://studio.iterative.ai/auth/device-login'
879+ }
880+ const mockCallbackUrl = 'url-to-vscode'
881+
882+ mockFetch . onFirstCall ( ) . resolves ( {
883+ json : ( ) => Promise . resolve ( mockStudioRes )
884+ } as Fetch . Response )
885+ mockGetCallbackUrl . onFirstCall ( ) . resolves ( mockCallbackUrl )
886+
887+ const callbackUriHandlerEvent : Promise < ( ) => unknown > = new Promise (
888+ resolve =>
889+ mockWaitForUriRes . onFirstCall ( ) . callsFake ( ( _ , onResponse ) => {
890+ resolve ( onResponse )
891+ } )
892+ )
862893
863894 mockMessageReceived . fire ( {
864- type : MessageFromWebviewType . OPEN_STUDIO
895+ type : MessageFromWebviewType . REQUEST_STUDIO_TOKEN
865896 } )
866897
867- await urlOpenedEvent
868- expect ( mockOpenExternal ) . to . be . calledWith (
869- Uri . parse ( 'https://studio.iterative.ai' )
898+ const mockOnStudioResponse = await callbackUriHandlerEvent
899+
900+ expect ( mockSendTelemetryEvent ) . to . be . calledOnce
901+ expect ( mockSendTelemetryEvent ) . to . be . calledWith (
902+ EventName . VIEWS_SETUP_REQUEST_STUDIO_TOKEN ,
903+ undefined ,
904+ undefined
905+ )
906+ expect ( mockFetch ) . to . be . calledOnce
907+ expect ( mockFetch ) . to . be . calledOnceWithExactly (
908+ `${ STUDIO_URL } /api/device-login` ,
909+ {
910+ body : JSON . stringify ( {
911+ client_name : 'VS Code'
912+ } ) ,
913+ headers : {
914+ 'Content-Type' : 'application/json'
915+ } ,
916+ method : 'POST'
917+ }
918+ )
919+ expect ( mockGetCallbackUrl ) . to . be . calledOnce
920+ expect ( mockGetCallbackUrl ) . to . be . calledWith ( '/studio-complete-auth' )
921+ expect ( mockOpenUrl ) . to . be . calledOnce
922+ expect ( mockOpenUrl ) . to . be . calledWith (
923+ `${ mockStudioRes . verification_uri } ?redirect_uri=${ mockCallbackUrl } &code=${ mockStudioRes . user_code } `
870924 )
871- } ) . timeout ( WEBVIEW_TEST_TIMEOUT )
872925
873- it ( "should handle a message from the webview to open the user's Studio profile" , async ( ) => {
874- const { setup, mockOpenExternal, urlOpenedEvent } = buildSetup ( {
875- disposer : disposable
876- } )
926+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
927+ const mockGetCwd = stub ( studio as any , 'getCwd' )
928+ const mockModalShowError = stub ( Modal , 'errorWithOptions' )
929+ const mockSaveStudioToken = stub ( studio , 'saveStudioAccessTokenInConfig' )
930+ mockFetch . onSecondCall ( ) . resolves ( {
931+ json : ( ) =>
932+ Promise . resolve ( { detail : 'Request failed for some reason.' } ) ,
933+ status : 500
934+ } as Fetch . Response )
935+
936+ const failedTokenEvent = new Promise ( resolve =>
937+ mockGetCwd . onFirstCall ( ) . callsFake ( ( ) => {
938+ resolve ( undefined )
939+ return dvcDemoPath
940+ } )
941+ )
877942
878- const webview = await setup . showWebview ( )
879- await webview . isReady ( )
943+ mockOnStudioResponse ( )
880944
881- const mockMessageReceived = getMessageReceivedEmitter ( webview )
945+ await failedTokenEvent
882946
883- mockMessageReceived . fire ( {
884- type : MessageFromWebviewType . OPEN_STUDIO_PROFILE
947+ expect ( mockFetch ) . to . be . calledTwice
948+ expect ( mockFetch ) . to . be . calledWithExactly ( mockStudioRes . token_uri , {
949+ body : JSON . stringify ( {
950+ code : mockStudioRes . device_code
951+ } ) ,
952+ headers : {
953+ 'Content-Type' : 'application/json'
954+ } ,
955+ method : 'POST'
885956 } )
957+ expect ( mockModalShowError ) . to . be . calledOnce
958+ expect ( mockModalShowError ) . to . be . calledWithExactly (
959+ 'Unable to get token. Failed with "Request failed for some reason."'
960+ )
961+ expect ( mockSaveStudioToken ) . not . to . be . called
886962
887- await urlOpenedEvent
888- expect ( mockOpenExternal ) . to . be . calledWith (
889- Uri . parse (
890- 'https://studio.iterative.ai/user/_/profile?section=accessToken'
891- )
963+ const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
964+ mockFetch . onThirdCall ( ) . resolves ( {
965+ json : ( ) => Promise . resolve ( { access_token : mockToken } ) ,
966+ status : 200
967+ } as Fetch . Response )
968+ const tokenEvent = new Promise ( resolve =>
969+ mockGetCwd . onSecondCall ( ) . callsFake ( ( ) => {
970+ resolve ( undefined )
971+ return dvcDemoPath
972+ } )
973+ )
974+
975+ mockOnStudioResponse ( )
976+
977+ await tokenEvent
978+
979+ expect ( mockFetch ) . to . be . calledThrice
980+ expect ( mockSaveStudioToken ) . to . be . calledOnce
981+ expect ( mockSaveStudioToken ) . to . be . calledWithExactly (
982+ dvcDemoPath ,
983+ mockToken
892984 )
893985 } ) . timeout ( WEBVIEW_TEST_TIMEOUT )
894986
895- it ( "should handle a message from the webview to save the user's Studio access token" , async ( ) => {
987+ it ( "should handle a message from the webview to manually save the user's Studio access token" , async ( ) => {
896988 const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
897989
898990 const { setup, mockExecuteCommand, messageSpy } = buildSetup ( {
0 commit comments