diff --git a/cypress/e2e/vault.cy.js b/cypress/e2e/vault.cy.js index 1cd13231..aab35ae1 100644 --- a/cypress/e2e/vault.cy.js +++ b/cypress/e2e/vault.cy.js @@ -1,580 +1,618 @@ /** * Copyright (C) 2019-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const progressbarColors = { 'new': 'rgba(128, 128, 128, 0.5)', 'pending': 'rgba(0, 0, 255, 0.5)', 'done': 'rgb(92, 184, 92)' }; function checkVaultCookingTask(objectType) { cy.contains('button', 'Download') .click(); cy.contains('.dropdown-item', objectType) .click(); cy.wait('@checkVaultCookingTask'); } function getVaultItemList() { return JSON.parse(window.localStorage.getItem('swh-vault-cooking-tasks')); } function updateVaultItemList(vaultItems) { window.localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultItems)); } // Mocks API response : /api/1/vault/(:bundleType)/(:swhid) // bundleType : {'flat', 'git_bare'} function genVaultCookingResponse(bundleType, swhid, status, message, fetchUrl) { return { 'bundle_type': bundleType, 'id': 1, 'progress_message': message, 'status': status, 'swhid': swhid, 'fetch_url': fetchUrl }; }; // Tests progressbar color, status // And status in localStorage function testStatus(taskId, color, statusMsg, status) { cy.get(`.swh-vault-table #vault-task-${CSS.escape(taskId)}`) .should('be.visible') .find('.progress-bar') .should('be.visible') .and('have.css', 'background-color', color) .and('contain', statusMsg) .then(() => { // Vault item with object_id as taskId should exist in localStorage const currentVaultItems = getVaultItemList(); const vaultItem = currentVaultItems.find(obj => obj.swhid === taskId); assert.isNotNull(vaultItem); assert.strictEqual(vaultItem.status, status); }); } describe('Vault Cooking User Interface Tests', function() { before(function() { const dirInfo = this.origin[0].directory[0]; this.directory = `swh:1:dir:${dirInfo.id}`; this.directoryUrl = this.Urls.browse_origin_directory() + `?origin_url=${this.origin[0].url}&path=${dirInfo.path}`; this.vaultDirectoryUrl = this.Urls.api_1_vault_cook_flat(this.directory); this.vaultFetchDirectoryUrl = this.Urls.api_1_vault_fetch_flat(this.directory); this.revisionId = this.origin[1].revisions[0]; this.revision = `swh:1:rev:${this.revisionId}`; this.revisionUrl = this.Urls.browse_revision(this.revisionId); this.vaultRevisionUrl = this.Urls.api_1_vault_cook_git_bare(this.revision); this.vaultFetchRevisionUrl = this.Urls.api_1_vault_fetch_git_bare(this.revision); const release = this.origin[1].release; this.releaseUrl = this.Urls.browse_release(release.id) + `?origin_url=${this.origin[1].url}`; this.vaultReleaseDirectoryUrl = this.Urls.api_1_vault_cook_flat(`swh:1:dir:${release.directory}`); }); beforeEach(function() { // For some reason, this gets reset if we define it in the before() hook, // so we need to define it here this.vaultItems = [ { 'bundle_type': 'git_bare', 'swhid': this.revision, 'email': '', 'status': 'done', 'fetch_url': `/api/1/vault/git-bare/${this.revision}/raw/`, 'progress_message': null } ]; this.legacyVaultItems = [ { 'object_type': 'revision', 'object_id': this.revisionId, 'email': '', 'status': 'done', 'fetch_url': `/api/1/vault/revision/${this.revisionId}/gitfast/raw/`, 'progress_message': null } ]; this.genVaultDirCookingResponse = (status, message = null) => { return genVaultCookingResponse('flat', this.directory, status, message, this.vaultFetchDirectoryUrl); }; this.genVaultRevCookingResponse = (status, message = null) => { return genVaultCookingResponse('git_bare', this.revision, status, message, this.vaultFetchRevisionUrl); }; }); + it('should report pending cooking task when already submitted', function() { + // Browse a directory + cy.visit(this.directoryUrl); + + // Stub responses when requesting the vault API to simulate + // an internal server error + cy.intercept(this.vaultDirectoryUrl, { + body: this.genVaultDirCookingResponse('pending', 'Processing...') + }).as('checkVaultCookingTask'); + + cy.intercept('POST', this.vaultDirectoryUrl, { + body: this.genVaultDirCookingResponse('pending', 'Processing...') + }).as('createVaultCookingTask'); + + cy.contains('button', 'Download') + .click(); + + // Create a vault cooking task through the GUI + cy.get('.modal-dialog') + .contains('button:visible', 'Ok') + .click(); + + cy.wait('@createVaultCookingTask'); + + // Check success alert is displayed + cy.get('.alert-success') + .should('be.visible') + .should('contain', 'Archive cooking request successfully submitted.'); + + // Go to Downloads page + cy.visit(this.Urls.vault()); + + cy.wait('@checkVaultCookingTask').then(() => { + testStatus(this.directory, progressbarColors['pending'], 'Processing...', 'pending'); + }); + + }); + it('should report an error when vault service is experiencing issues', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // an internal server error cy.intercept(this.vaultDirectoryUrl, { body: {'exception': 'APIError'}, statusCode: 500 }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') - .should('contain', 'Archive cooking service is currently experiencing issues.'); + .should('contain', 'Something unexpected happened when requesting the archive cooking service.'); }); it('should report an error when a cooking task creation failed', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // a task can not be created cy.intercept('GET', this.vaultDirectoryUrl, { body: {'exception': 'NotFoundExc'} }).as('checkVaultCookingTask'); cy.intercept('POST', this.vaultDirectoryUrl, { body: {'exception': 'ValueError'}, statusCode: 500 }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') .should('contain', 'Archive cooking request submission failed.'); }); it('should display previous cooking tasks', function() { updateVaultItemList(this.vaultItems); cy.visit(this.Urls.vault()); cy.contains(`#vault-task-${CSS.escape(this.revision)} button`, 'Download') .click(); }); it('should display and upgrade previous cooking tasks from the legacy format', function() { updateVaultItemList(this.legacyVaultItems); cy.visit(this.Urls.vault()); // Check it is displayed cy.contains(`#vault-task-${CSS.escape(this.revision)} button`, 'Download') .then(() => { // Check the LocalStorage was upgraded expect(getVaultItemList()).to.deep.equal(this.vaultItems); }); }); it('should create a directory cooking task and report the success', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept('GET', this.vaultFetchDirectoryUrl, { fixture: `${this.directory.replace(/:/g, '_')}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory.replace(/:/g, '_')}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaulResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new'), this.genVaultDirCookingResponse('pending', 'Processing...'), this.genVaultDirCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultDirectoryUrl, req => req.reply(checkVaulResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseDirectoryUrl = swhIdsContext.directory.swhid_with_context_url; // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-origin a`) .should('contain', this.origin[0].url) .should('have.attr', 'href', `${this.Urls.browse_origin()}?origin_url=${this.origin[0].url}`); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-object-info a`) .should('have.text', this.directory) .should('have.attr', 'href', browseDirectoryUrl); cy.get(`#vault-task-${CSS.escape(this.directory)}`) .invoke('attr', 'title') .should('contain', 'the directory can be extracted'); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a revision cooking task and report its status', function() { cy.adminLogin(); // Browse a revision cy.visit(this.revisionUrl); // Stub response to the vault API indicating to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { fixture: `${this.revision.replace(/:/g, '_')}.git.tar`, headers: { 'Content-disposition': `attachment; filename=${this.revision.replace(/:/g, '_')}.git.tar`, 'Content-Type': 'application/x-tar' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultRevCookingResponse('new'), this.genVaultRevCookingResponse('pending', 'Processing...'), this.genVaultRevCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultRevisionUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); // Create a vault cooking task through the GUI checkVaultCookingTask('as git'); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseRevisionUrl = swhIdsContext.revision.swhid_url; // Create a vault cooking task through the GUI cy.get('.modal.show') .type('{enter}'); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-origin`) .should('have.text', 'unknown'); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-object-info a`) .should('have.text', this.revision) .should('have.attr', 'href', browseRevisionUrl); cy.get(`#vault-task-${CSS.escape(this.revision)}`) .invoke('attr', 'title') .should('contain', 'the git repository can be imported'); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a directory cooking task from the release view', function() { // Browse a directory cy.visit(this.releaseUrl); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultReleaseDirectoryUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultReleaseDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); }); it('should create a directory cooking task with an email address', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when checking vault task status cy.intercept('GET', this.vaultDirectoryUrl, {body: {'exception': 'NotFoundExc'}}) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultDirectoryUrl + '?email=foo%2Bbar%40example.org', { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); // Open vault cook directory modal cy.contains('button', 'Download') .click(); cy.wait('@checkVaultCookingTask'); // Create a vault cooking task through the GUI and fill email input cy.get('#vault-cook-directory-modal input[type="email"]') .type('foo+bar@example.org', {force: true}); cy.get('#vault-cook-directory-modal') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); }); it('should offer to recook an archive if no longer available for download', function() { updateVaultItemList(this.vaultItems); // Send 404 when fetching vault item cy.intercept({url: this.vaultFetchRevisionUrl}, { statusCode: 404, body: { 'exception': 'NotFoundExc', 'reason': `Revision with ID '${this.revision}' not found.` }, headers: { 'Content-Type': 'json' } }).as('fetchCookedArchive'); cy.visit(this.Urls.vault()) .get(`#vault-task-${CSS.escape(this.revision)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then(() => { cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('checkVaultCookingTask'); cy.get('#vault-recook-object-modal > .modal-dialog') .should('be.visible') .contains('button:visible', 'Ok') .click(); cy.wait('@checkVaultCookingTask') .then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); }); }); it('should remove selected vault items', function() { updateVaultItemList(this.vaultItems); cy.visit(this.Urls.vault()) .get(`#vault-task-${CSS.escape(this.revision)}`) .find('input[type="checkbox"]') .click({force: true}); cy.contains('button', 'Remove selected tasks') .click(); cy.get(`#vault-task-${CSS.escape(this.revision)}`) .should('not.exist'); }); it('should offer to immediately download a directory tarball if already cooked', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchDirectoryUrl}, { fixture: `${this.directory.replace(/:/g, '_')}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory.replace(/:/g, '_')}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('done') }).as('checkVaultCookingTask'); // Create a vault cooking task through the GUI cy.contains('button', 'Download') .click(); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); it('should offer to immediately download a bare revision git archive if already cooked', function() { cy.adminLogin(); // Browse a directory cy.visit(this.revisionUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { fixture: `${this.revision.replace(/:/g, '_')}.git.tar`, headers: { 'Content-disposition': `attachment; filename=${this.revision.replace(/:/g, '_')}.git.tar`, 'Content-Type': 'application/x-tar' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('done') }).as('checkVaultCookingTask'); checkVaultCookingTask('as git'); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); it('should offer to recook an object if previous vault task failed', function() { cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // the last cooking of the directory tarball has failed cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('failed') }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check that recooking the directory is offered to user cy.get('.modal-dialog') .contains('button:visible', 'Ok') .should('be.visible'); }); }); diff --git a/swh/web/vault/assets/vault-create-tasks.js b/swh/web/vault/assets/vault-create-tasks.js index d954eb35..308398b9 100644 --- a/swh/web/vault/assets/vault-create-tasks.js +++ b/swh/web/vault/assets/vault-create-tasks.js @@ -1,171 +1,172 @@ /** * Copyright (C) 2018-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import * as EmailValidator from 'email-validator'; import {csrfPost, handleFetchError, htmlAlert} from 'utils/functions'; const alertStyle = { 'position': 'fixed', 'left': '1rem', 'bottom': '1rem', 'z-index': '100000' }; function vaultModalHandleEnterKey(event) { if (event.keyCode === 13) { event.preventDefault(); $('.modal.show').last().find('button:contains("Ok")').trigger('click'); } } export async function vaultRequest(objectType, swhid) { let vaultUrl; if (objectType === 'directory') { vaultUrl = Urls.api_1_vault_cook_flat(swhid); } else { vaultUrl = Urls.api_1_vault_cook_git_bare(swhid); } // check if object has already been cooked const response = await fetch(vaultUrl); const data = await response.json(); // object needs to be cooked - if (data.exception === 'NotFoundExc' || data.status === 'failed') { + const statusForCooking = ['failed', 'pending', 'new']; + if (data.exception === 'NotFoundExc' || statusForCooking.includes(data.status)) { // if last cooking has failed, remove previous task info from localStorage // in order to force the recooking of the object swh.vault.removeCookingTaskInfo([swhid]); const vaultModalId = `#vault-cook-${objectType}-modal`; $(vaultModalId).modal('show'); $('body').on('keyup', vaultModalId, vaultModalHandleEnterKey); // object has been cooked and should be in the vault cache, // it will be asked to cook it again if it is not } else if (data.status === 'done') { const vaultModalId = `#vault-fetch-${objectType}-modal`; $(vaultModalId).modal('show'); $('body').on('keyup', vaultModalId, vaultModalHandleEnterKey); } else { const cookingServiceDownAlert = $(htmlAlert('danger', - 'Archive cooking service is currently experiencing issues.
' + + 'Something unexpected happened when requesting the archive cooking service.
' + 'Please try again later.', true)); cookingServiceDownAlert.css(alertStyle); $('body').append(cookingServiceDownAlert); } } async function addVaultCookingTask(objectType, cookingTask) { const swhidsContext = swh.webapp.getSwhIdsContext(); cookingTask.origin = swhidsContext[objectType].context.origin; cookingTask.path = swhidsContext[objectType].context.path; cookingTask.browse_url = swhidsContext[objectType].swhid_with_context_url; if (!cookingTask.browse_url) { cookingTask.browse_url = swhidsContext[objectType].swhid_url; } let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks) { vaultCookingTasks = []; } if (vaultCookingTasks.find(val => { return val.bundle_type === cookingTask.bundle_type && val.swhid === cookingTask.swhid; }) === undefined) { let cookingUrl; if (cookingTask.bundle_type === 'flat') { cookingUrl = Urls.api_1_vault_cook_flat(cookingTask.swhid); } else { cookingUrl = Urls.api_1_vault_cook_git_bare(cookingTask.swhid); } if (cookingTask.email) { cookingUrl += '?email=' + encodeURIComponent(cookingTask.email); } try { const response = await csrfPost(cookingUrl); handleFetchError(response); vaultCookingTasks.push(cookingTask); localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); $('#vault-cook-directory-modal').modal('hide'); $('body').off('keyup', '#vault-cook-directory-modal', vaultModalHandleEnterKey); $('#vault-cook-revision-modal').modal('hide'); $('body').off('keyup', '#vault-cook-revision-modal', vaultModalHandleEnterKey); const cookingTaskCreatedAlert = $(htmlAlert('success', 'Archive cooking request successfully submitted.
' + `Go to the Downloads page ` + 'to get the download link once it is ready.', true)); cookingTaskCreatedAlert.css(alertStyle); $('body').append(cookingTaskCreatedAlert); } catch (_) { $('#vault-cook-directory-modal').modal('hide'); $('body').off('keyup', '#vault-cook-directory-modal', vaultModalHandleEnterKey); $('#vault-cook-revision-modal').modal('hide'); $('body').off('keyup', '#vault-cook-revision-modal', vaultModalHandleEnterKey); const cookingTaskFailedAlert = $(htmlAlert('danger', 'Archive cooking request submission failed.', true)); cookingTaskFailedAlert.css(alertStyle); $('body').append(cookingTaskFailedAlert); } } } export function cookDirectoryArchive(swhid) { const email = $('#swh-vault-directory-email').val().trim(); if (!email || EmailValidator.validate(email)) { const cookingTask = { 'bundle_type': 'flat', 'swhid': swhid, 'email': email, 'status': 'new' }; addVaultCookingTask('directory', cookingTask); } else { $('#invalid-email-modal').modal('show'); $('body').on('keyup', '#invalid-email-modal', vaultModalHandleEnterKey); } } export async function fetchDirectoryArchive(directorySwhid) { $('#vault-fetch-directory-modal').modal('hide'); $('body').off('keyup', '#vault-cook-revision-modal', vaultModalHandleEnterKey); const vaultUrl = Urls.api_1_vault_cook_flat(directorySwhid); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); } export function cookRevisionArchive(revisionId) { const email = $('#swh-vault-revision-email').val().trim(); if (!email || EmailValidator.validate(email)) { const cookingTask = { 'bundle_type': 'git_bare', 'swhid': revisionId, 'email': email, 'status': 'new' }; addVaultCookingTask('revision', cookingTask); } else { $('#invalid-email-modal').modal('show'); $('body').on('keyup', '#invalid-email-modal', vaultModalHandleEnterKey); } } export async function fetchRevisionArchive(revisionSwhid) { $('#vault-fetch-revision-modal').modal('hide'); $('body').off('keyup', '#vault-fetch-revision-modal', vaultModalHandleEnterKey); const vaultUrl = Urls.api_1_vault_cook_git_bare(revisionSwhid); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); }