VirtualBox

Changeset 93842 in vbox


Ignore:
Timestamp:
Feb 18, 2022 2:25:05 PM (3 years ago)
Author:
vboxsync
Message:

Validation Kit: Extended unittest test driver so that it also can install + execute unittests on remote targets (i.e. older / unsupported guests). To continue running testcases locally, the "--local" switch has to specified. Currently using the smoke test set (NAT) by default. bugref:10195

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/VBox/ValidationKit/tests/unittests/tdUnitTest1.py

    r93208 r93842  
    245245    ];
    246246
     247    # White list, which contains tests considered to be safe to execute,
     248    # even on remote targets (guests).
     249    kdTestCasesWhiteList = {
     250        'testcase/tstFile': '',
     251        'testcase/tstFileLock': '',
     252        'testcase/tstRTLocalIpc': '',
     253        'testcase/tstRTPathQueryInfo': '',
     254        'testcase/tstRTPipe': '',
     255        'testcase/tstRTProcCreateEx': '',
     256        'testcase/tstRTProcCreatePrf': '',
     257        'testcase/tstRTProcIsRunningByName': '',
     258        'testcase/tstRTProcQueryUsername': '',
     259        'testcase/tstRTProcWait': '',
     260        'testcase/tstTime-2': '',
     261        'testcase/tstTime-3': '',
     262        'testcase/tstTime-4': '',
     263        'testcase/tstTimer': '',
     264        'testcase/tstThread-1': '',
     265        'testcase/tstUtf8': '',
     266    }
     267
     268    # Test dependency list -- libraries.
     269    # Needed in order to execute testcases on remote targets which don't have a VBox installation present.
     270    kdTestCaseDepsLibs = [
     271        "VBoxRT"
     272    ];
     273
    247274    ## The exclude list.
    248275    # @note Stripped extensions!
     
    307334        Reinitialize child class instance.
    308335        """
    309         vbox.TestDriver.__init__(self)
    310         self.oTestVmSet = None;
    311 
    312         self.sVBoxInstallRoot = None
    313 
    314         self.cSkipped   = 0
    315         self.cPassed    = 0
    316         self.cFailed    = 0
    317 
    318         self.sUnitTestsPathBase = None
     336        vbox.TestDriver.__init__(self);
     337
     338        # We need to set a default test VM set here -- otherwise the test
     339        # driver base class won't let us use the "--test-vms" switch.
     340        #
     341        # See the "--local" switch in self.parseOption().
     342        self.oTestVmSet = self.oTestVmManager.getSmokeVmSet('nat');
     343
     344        # Session handling stuff.
     345        # Only needed for remote tests executed by TxS.
     346        self.oSession    = None;
     347        self.oTxsSession = None;
     348
     349        self.sVBoxInstallRoot = None;
     350
     351        self.cSkipped   = 0;
     352        self.cPassed    = 0;
     353        self.cFailed    = 0;
     354
     355        # The source directory where our unit tests live.
     356        # This most likely is our out/ or some staging directory and
     357        # also acts the source for copying over the testcases to a remote target.
     358        self.sUnitTestsPathSrc = None;
     359
     360        # Array of environment variables with NAME=VAL entries
     361        # to be applied for testcases.
     362        #
     363        # This is also needed for testcases which are being executed remotely.
     364        self.asEnv = [];
     365
     366        # The destination directory our unit tests live when being
     367        # copied over to a remote target (via TxS).
     368        self.sUnitTestsPathDst = None;
     369
    319370        self.sExeSuff   = '.exe' if utils.getHostOs() in [ 'win', 'dos', 'os2' ] else '';
    320371
     
    322373
    323374        # For testing testcase logic.
    324         self.fDryRun    = False;
     375        self.fDryRun        = False;
     376        self.fOnlyWhiteList = False;
    325377
    326378
     
    335387        # We need a VBox install (/ build) to test.
    336388        #
    337         if False is True:
     389        if False is True: ## @todo r=andy WTF?
    338390            if not self.importVBoxApi():
    339391                reporter.error('Unabled to import the VBox Python API.')
     
    387439        for sCandidat in asCandidates:
    388440            if os.path.exists(os.path.join(sCandidat, 'testcase', 'tstVMStructSize' + self.sExeSuff)):
    389                 self.sUnitTestsPathBase = sCandidat;
     441                self.sUnitTestsPathSrc = sCandidat;
    390442                return True;
    391443
     
    397449    #
    398450
     451    def showUsage(self):
     452        """
     453        Shows the testdriver usage.
     454        """
     455        fRc = vbox.TestDriver.showUsage(self);
     456        reporter.log('');
     457        reporter.log('Unit Test #1 options:');
     458        reporter.log('  --dryrun');
     459        reporter.log('      Performs a dryrun (no tests being executed).');
     460        reporter.log('  --local');
     461        reporter.log('      Runs unit tests locally (this host).');
     462        reporter.log('  --only-whitelist');
     463        reporter.log('      Only processes the white list.');
     464        reporter.log('  --quick');
     465        reporter.log('      Very selective testing.');
     466        return fRc;
     467
     468    def parseOption(self, asArgs, iArg):
     469        """
     470        Parses the testdriver arguments from the command line.
     471        """
     472        if asArgs[iArg] == '--dryrun':
     473            self.fDryRun = True;
     474        elif asArgs[iArg] == '--local':
     475            # Disable the test VM set and only run stuff locally.
     476            self.oTestVmSet = None;
     477        elif asArgs[iArg] == '--only-whitelist':
     478            self.fOnlyWhiteList = True;
     479        elif asArgs[iArg] == '--quick':
     480            self.fOnlyWhiteList = True;
     481        else:
     482            return vbox.TestDriver.parseOption(self, asArgs, iArg);
     483        return iArg + 1;
     484
    399485    def actionVerify(self):
    400         return self._detectPaths();
     486        if not self._detectPaths():
     487            return False;
     488
     489        if self.oTestVmSet:
     490            return vbox.TestDriver.actionVerify(self);
     491
     492        return True;
     493
     494    def actionConfig(self):
     495        # Make sure vboxapi has been imported so we can use the constants.
     496        if not self.importVBoxApi():
     497            return False;
     498
     499        # Do the configuring.
     500        if self.oTestVmSet:
     501            if   self.sNicAttachment == 'nat':     eNic0AttachType = vboxcon.NetworkAttachmentType_NAT;
     502            elif self.sNicAttachment == 'bridged': eNic0AttachType = vboxcon.NetworkAttachmentType_Bridged;
     503            else:                                  eNic0AttachType = None;
     504            return self.oTestVmSet.actionConfig(self, eNic0AttachType = eNic0AttachType);
     505
     506        return True;
    401507
    402508    def actionExecute(self):
    403 
    404         if self.sUnitTestsPathBase is None and self._detectPaths():
     509        if self.sUnitTestsPathSrc is None and not self._detectPaths():
    405510            return False;
    406511
    407         self._figureVersion();
    408         self._makeEnvironmentChanges();
    409 
    410         self.testRunUnitTestsSet(r'^tst*', 'testcase')
    411         self.testRunUnitTestsSet(r'^tst*', '.')
    412 
    413         reporter.log('')
    414         reporter.log('********************')
    415         reporter.log('***  PASSED: %d' % self.cPassed)
    416         reporter.log('***  FAILED: %d' % self.cFailed)
    417         reporter.log('*** SKIPPED: %d' % self.cSkipped)
    418         reporter.log('***   TOTAL: %d' % (self.cPassed + self.cFailed + self.cSkipped))
    419 
    420         return self.cFailed == 0
     512        if not self.sUnitTestsPathDst:
     513            self.sUnitTestsPathDst = self.sScratchPath;
     514        reporter.log('Unit test destination path is "%s"\n' % self.sUnitTestsPathDst);
     515
     516        if self.oTestVmSet: # Run on a test VM (guest).
     517            fRc = self.oTestVmSet.actionExecute(self, self.testOneVmConfig);
     518        else: # Run locally (host).
     519            self._figureVersion();
     520            self._makeEnvironmentChanges();
     521            fRc = self._testRunUnitTests(None);
     522
     523        return fRc;
     524
     525    #
     526    # Test execution helpers.
     527    #
     528
     529    def _testRunUnitTests(self, oTestVm):
     530        """
     531        Main function to execute all unit tests.
     532        """
     533        self._testRunUnitTestsSet(r'^tst*', 'testcase');
     534        self._testRunUnitTestsSet(r'^tst*', '.');
     535
     536        fRc = self.cFailed == 0;
     537
     538        reporter.log('');
     539        reporter.log('********************');
     540        reporter.log('Target: %s' % (oTestVm.sVmName if oTestVm else 'local'));
     541        reporter.log('********************');
     542        reporter.log('***  PASSED: %d' % self.cPassed);
     543        reporter.log('***  FAILED: %d' % self.cFailed);
     544        reporter.log('*** SKIPPED: %d' % self.cSkipped);
     545        reporter.log('***   TOTAL: %d' % (self.cPassed + self.cFailed + self.cSkipped));
     546
     547        return fRc;
     548
     549
     550    def testOneVmConfig(self, oVM, oTestVm):
     551        """
     552        Runs the specified VM thru test #1.
     553        """
     554
     555        # Simple test.
     556        self.logVmInfo(oVM);
     557        # Try waiting for a bit longer (5 minutes) until the CD is available to avoid running into timeouts.
     558        self.oSession, self.oTxsSession = self.startVmAndConnectToTxsViaTcp(oTestVm.sVmName, fCdWait = False);
     559        if self.oSession is not None:
     560            self.addTask(self.oTxsSession);
     561
     562            # Determine the unit tests destination path.
     563            self.sUnitTestsPathDst = oTestVm.pathJoin(self.getGuestTempDir(oTestVm), 'testUnitTests');
     564
     565            # Run the unit tests.
     566            self._testRunUnitTests(oTestVm);
     567
     568            # Cleanup.
     569            self.removeTask(self.oTxsSession);
     570            self.terminateVmBySession(self.oSession);
     571            return True;
     572
     573        return False;
    421574
    422575    #
     
    514667        """
    515668        reporter.log('Executing [sudo]: %s' % (asArgs, ));
    516         try:
    517             iRc = utils.sudoProcessCall(asArgs, shell = False, close_fds = False);
    518         except:
    519             reporter.errorXcpt();
    520             return False;
    521         reporter.log('Exit code [sudo]: %s (%s)' % (iRc, asArgs));
     669        if self.oTestVmSet:
     670            iRc = -1; ## @todo Not used remotely yet.
     671        else:
     672            try:
     673                iRc = utils.sudoProcessCall(asArgs, shell = False, close_fds = False);
     674            except:
     675                reporter.errorXcpt();
     676                return False;
     677            reporter.log('Exit code [sudo]: %s (%s)' % (iRc, asArgs));
    522678        return iRc == 0;
    523679
     680    def _hardenedPathExists(self, sPath):
     681        """
     682        Creates the directory specified sPath (including parents).
     683        """
     684        reporter.log('_hardenedPathExists: %s' % (sPath,));
     685        fRc = False;
     686        if self.oTestVmSet:
     687            fRc = self.txsIsDir(self.oSession, self.oTxsSession, sPath, fIgnoreErrors = True);
     688            if not fRc:
     689                fRc = self.txsIsFile(self.oSession, self.oTxsSession, sPath, fIgnoreErrors = True);
     690        else:
     691            fRc = os.path.exists(sPath);
     692        return fRc;
     693
    524694    def _hardenedMkDir(self, sPath):
    525695        """
     
    527697        """
    528698        reporter.log('_hardenedMkDir: %s' % (sPath,));
    529         if utils.getHostOs() in [ 'win', 'os2' ]:
    530             os.makedirs(sPath, 0o755);
     699        fRc = True;
     700        if self.oTestVmSet:
     701            fRc = self.txsMkDirPath(self.oSession, self.oTxsSession, sPath, fMode = 0o755);
    531702        else:
    532             fRc = self._sudoExecuteSync(['/bin/mkdir', '-p', '-m', '0755', sPath]);
    533             if fRc is not True:
    534                 raise Exception('Failed to create dir "%s".' % (sPath,));
     703            if utils.getHostOs() in [ 'win', 'os2' ]:
     704                os.makedirs(sPath, 0o755);
     705            else:
     706                fRc = self._sudoExecuteSync(['/bin/mkdir', '-p', '-m', '0755', sPath]);
     707        if fRc is not True:
     708            raise Exception('Failed to create dir "%s".' % (sPath,));
    535709        return True;
    536710
     
    540714        """
    541715        reporter.log('_hardenedCopyFile: %s -> %s (mode: %o)' % (sSrc, sDst, iMode,));
    542         if utils.getHostOs() in [ 'win', 'os2' ]:
    543             utils.copyFileSimple(sSrc, sDst);
    544             os.chmod(sDst, iMode);
     716        fRc = True;
     717        if self.oTestVmSet:
     718            fRc = self.txsUploadFile(self.oSession, self.oTxsSession, sSrc, sDst);
     719            if fRc:
     720                self.oTxsSession.syncChMod(sDst, 0o755);
    545721        else:
    546             fRc = self._sudoExecuteSync(['/bin/cp', sSrc, sDst]);
    547             if fRc is not True:
    548                 raise Exception('Failed to copy "%s" to "%s".' % (sSrc, sDst,));
    549             fRc = self._sudoExecuteSync(['/bin/chmod', '%o' % (iMode,), sDst]);
    550             if fRc is not True:
    551                 raise Exception('Failed to chmod "%s".' % (sDst,));
     722            if utils.getHostOs() in [ 'win', 'os2' ]:
     723                utils.copyFileSimple(sSrc, sDst);
     724                os.chmod(sDst, iMode);
     725            else:
     726                fRc = self._sudoExecuteSync(['/bin/cp', sSrc, sDst]);
     727                if fRc:
     728                    fRc = self._sudoExecuteSync(['/bin/chmod', '%o' % (iMode,), sDst]);
     729                    if fRc is not True:
     730                        raise Exception('Failed to chmod "%s".' % (sDst,));
     731        if fRc is not True:
     732            raise Exception('Failed to copy "%s" to "%s".' % (sSrc, sDst,));
    552733        return True;
    553734
     
    557738        """
    558739        reporter.log('_hardenedDeleteFile: %s' % (sPath,));
    559         if os.path.exists(sPath):
    560             if utils.getHostOs() in [ 'win', 'os2' ]:
    561                 os.remove(sPath);
    562             else:
    563                 fRc = self._sudoExecuteSync(['/bin/rm', sPath]);
    564                 if fRc is not True:
    565                     raise Exception('Failed to remove "%s".' % (sPath,));
     740        fRc = True;
     741        if self.oTestVmSet:
     742            if self.txsIsFile(self.oSession, self.oTxsSession, sPath):
     743                fRc = self.txsRmFile(self.oSession, self.oTxsSession, sPath);
     744        else:
     745            if os.path.exists(sPath):
     746                if utils.getHostOs() in [ 'win', 'os2' ]:
     747                    os.remove(sPath);
     748                else:
     749                    fRc = self._sudoExecuteSync(['/bin/rm', sPath]);
     750        if fRc is not True:
     751            raise Exception('Failed to remove "%s".' % (sPath,));
    566752        return True;
    567753
     
    571757        """
    572758        reporter.log('_hardenedRemoveDir: %s' % (sPath,));
    573         if os.path.exists(sPath):
    574             if utils.getHostOs() in [ 'win', 'os2' ]:
    575                 os.rmdir(sPath);
    576             else:
    577                 fRc = self._sudoExecuteSync(['/bin/rmdir', sPath]);
    578                 if fRc is not True:
    579                     raise Exception('Failed to remove "%s".' % (sPath,));
     759        fRc = True;
     760        if self.oTestVmSet:
     761            if self.txsIsDir(self.oSession, self.oTxsSession, sPath):
     762                fRc = self.txsRmDir(self.oSession, self.oTxsSession, sPath);
     763        else:
     764            if os.path.exists(sPath):
     765                if utils.getHostOs() in [ 'win', 'os2' ]:
     766                    os.rmdir(sPath);
     767                else:
     768                    fRc = self._sudoExecuteSync(['/bin/rmdir', sPath]);
     769        if fRc is not True:
     770            raise Exception('Failed to remove "%s".' % (sPath,));
     771        return True;
     772
     773    def _envSet(self, sName, sValue):
     774        if self.oTestVmSet:
     775            # For remote execution we cache the environment block and pass it
     776            # right when the process execution happens.
     777            self.asEnv.append([ sName, sValue ]);
     778        else:
     779            os.environ[sName] = sValue;
    580780        return True;
    581781
     
    594794        #
    595795        fHardened       = False;
     796        fToRemote       = False;
     797        fCopyDeps       = False;
    596798        asFilesToRemove = []; # Stuff to clean up.
    597799        asDirsToRemove  = []; # Ditto.
    598         if    sName in self.kasHardened \
    599           and self.sUnitTestsPathBase != self.sVBoxInstallRoot:
    600 
    601             sDstDir = os.path.join(self.sVBoxInstallRoot, sTestCaseSubDir);
    602             if not os.path.exists(sDstDir):
     800
     801        if  sName in self.kasHardened \
     802        and self.sUnitTestsPathSrc != self.sVBoxInstallRoot:
     803            fHardened = True;
     804
     805        if self.oTestVmSet:
     806            fToRemote = True;
     807            fCopyDeps = True;
     808
     809        if fHardened \
     810        or fToRemote:
     811            if fToRemote:
     812                sDstDir = os.path.join(self.sUnitTestsPathDst, sTestCaseSubDir);
     813            else:
     814                sDstDir = os.path.join(self.sVBoxInstallRoot, sTestCaseSubDir);
     815            if not self._hardenedPathExists(sDstDir):
    603816                self._hardenedMkDir(sDstDir);
    604817                asDirsToRemove.append(sDstDir);
     
    607820            self._hardenedCopyFile(sFullPath, sDst, 0o755);
    608821            asFilesToRemove.append(sDst);
     822
     823            # Copy required dependencies to destination.
     824            if fCopyDeps:
     825                for sLib in self.kdTestCaseDepsLibs:
     826                    for sSuff in [ '.dll', '.so', '.dylib' ]:
     827                        sSrc = os.path.join(self.sVBoxInstallRoot, sLib + sSuff);
     828                        if os.path.exists(sSrc):
     829                            sDst = os.path.join(sDstDir, os.path.basename(sSrc));
     830                            self._hardenedCopyFile(sSrc, sDst, 0o644);
     831                            asFilesToRemove.append(sDst);
    609832
    610833            # Copy any associated .dll/.so/.dylib.
     
    627850
    628851            sFullPath = os.path.join(sDstDir, os.path.basename(sFullPath));
    629             fHardened = True;
    630852
    631853        #
     
    636858            asArgs.extend(self.kdArguments[sName]);
    637859
    638         os.environ['IPRT_TEST_OMIT_TOP_TEST'] = '1';
    639         os.environ['IPRT_TEST_FILE'] = sXmlFile = os.path.join(self.sScratchPath, 'result.xml');
    640         if os.path.exists(sXmlFile):
     860        sXmlFile = os.path.join(self.sUnitTestsPathDst, 'result.xml');
     861
     862        self._envSet('IPRT_TEST_OMIT_TOP_TEST', '1');
     863        self._envSet('IPRT_TEST_FILE', sXmlFile);
     864
     865        if self._hardenedPathExists(sXmlFile):
    641866            try:    os.unlink(sXmlFile);
    642867            except: self._hardenedDeleteFile(sXmlFile);
     
    656881        except: pass;
    657882        if not self.fDryRun:
    658             try:
    659                 if fHardened:
    660                     oChild = utils.sudoProcessPopen(asArgs, stdin = oDevNull, stdout = sys.stdout, stderr = sys.stdout);
     883            if fToRemote:
     884                fRc = self.txsRunTest(self.oTxsSession, sName, 30 * 60 * 1000, asArgs[0], asArgs, self.asEnv, \
     885                                      fCheckSessionStatus = True);
     886                if fRc:
     887                    iRc = 0;
    661888                else:
    662                     oChild = utils.processPopenSafe(asArgs, stdin = oDevNull, stdout = sys.stdout, stderr = sys.stdout);
    663             except:
    664                 if sName in [ 'tstAsmStructsRC',    # 32-bit, may fail to start on 64-bit linux. Just ignore.
    665                             ]:
    666                     reporter.logXcpt();
    667                     fSkipped = True;
    668                 else:
    669                     reporter.errorXcpt();
    670                 iRc    = 1023;
    671                 oChild = None;
    672 
    673             if oChild is not None:
    674                 self.pidFileAdd(oChild.pid, sName, fSudo = fHardened);
    675                 iRc = oChild.wait();
    676                 self.pidFileRemove(oChild.pid);
     889                    (_, sOpcode, abPayload) = self.oTxsSession.getLastReply();
     890                    if sOpcode.startswith('PROC NOK '): # Extract process rc.
     891                        iRc = abPayload[0]; # ASSUMES 8-bit rc for now.
     892                        if iRc == 0: # Might happen if the testcase misses some dependencies. Set it to -42 then.
     893                            iRc = -42;
     894                    else:
     895                        iRc = -1; ## @todo
     896            else:
     897                try:
     898                    if fHardened:
     899                        oChild = utils.sudoProcessPopen(asArgs, stdin = oDevNull, stdout = sys.stdout, stderr = sys.stdout);
     900                    else:
     901                        oChild = utils.processPopenSafe(asArgs, stdin = oDevNull, stdout = sys.stdout, stderr = sys.stdout);
     902                except:
     903                    if sName in [ 'tstAsmStructsRC',    # 32-bit, may fail to start on 64-bit linux. Just ignore.
     904                                ]:
     905                        reporter.logXcpt();
     906                        fSkipped = True;
     907                    else:
     908                        reporter.errorXcpt();
     909                    iRc    = 1023;
     910                    oChild = None;
     911
     912                if oChild is not None:
     913                    self.pidFileAdd(oChild.pid, sName, fSudo = fHardened);
     914                    iRc = oChild.wait();
     915                    self.pidFileRemove(oChild.pid);
    677916        else:
    678917            iRc = 0;
     
    698937        if iRc == 0:
    699938            reporter.log('*** %s: exit code %d' % (sFullPath, iRc));
    700             self.cPassed += 1
     939            self.cPassed += 1;
    701940
    702941        elif iRc == 4: # RTEXITCODE_SKIPPED
     
    721960            else:
    722961                reporter.error('!*! %s: exit code %d%s' % (sFullPath, iRc, sName));
    723             self.cFailed += 1
     962            self.cFailed += 1;
    724963
    725964        return fSkipped;
    726965
    727     def testRunUnitTestsSet(self, sTestCasePattern, sTestCaseSubDir):
     966    def _testRunUnitTestsSet(self, sTestCasePattern, sTestCaseSubDir):
    728967        """
    729968        Run subset of the unit tests set.
     
    740979        dTestCasesBuggyForHostOs.update(self.kdTestCasesBuggyPerOs.get(utils.getHostOsDotArch(), []));
    741980
    742         ## @todo Add filtering for more specifc OSes (like OL server, doesn't have X installed) by adding a separate
     981        ## @todo Add filtering for more specific OSes (like OL server, doesn't have X installed) by adding a separate
    743982        #        black list + using utils.getHostOsVersion().
    744983
     
    746985        # Process the file list and run everything looking like a testcase.
    747986        #
    748         for sFilename in sorted(os.listdir(os.path.join(self.sUnitTestsPathBase, sTestCaseSubDir))):
     987        for sFilename in sorted(os.listdir(os.path.join(self.sUnitTestsPathSrc, sTestCaseSubDir))):
    749988            # Separate base and suffix and morph the base into something we
    750989            # can use for reporting and array lookups.
     
    753992                sName = sTestCaseSubDir + '/' + sName;
    754993
     994            # Process white list first, if set.
     995            if  self.fOnlyWhiteList \
     996            and sName not in self.kdTestCasesWhiteList:
     997                reporter.log2('"%s" is not in white list, skipping.' % (sFilename,));
     998                continue;
     999
    7551000            # Basic exclusion.
    7561001            if   not re.match(sTestCasePattern, sFilename) \
    7571002              or sSuffix in self.kasSuffixBlackList:
    758                 reporter.log('"%s" is not a test case.' % (sFilename,))
    759                 continue
     1003                reporter.log2('"%s" is not a test case.' % (sFilename,));
     1004                continue;
    7601005
    7611006            # Check if the testcase is black listed or buggy before executing it.
     
    7771022
    7781023            else:
    779                 sFullPath = os.path.normpath(os.path.join(self.sUnitTestsPathBase, os.path.join(sTestCaseSubDir, sFilename)));
     1024                sFullPath = os.path.normpath(os.path.join(self.sUnitTestsPathSrc, os.path.join(sTestCaseSubDir, sFilename)));
    7801025                reporter.testStart(sName);
    7811026                try:
Note: See TracChangeset for help on using the changeset viewer.

© 2024 Oracle Support Privacy / Do Not Sell My Info Terms of Use Trademark Policy Automated Access Etiquette