mirror of
https://github.com/redhat-developer/odo.git
synced 2025-10-19 03:06:19 +03:00
Propagate local Devfile changes to the UI (#6970)
* Add '/notifications' endpoint for subscribing to server-sent events * Generate server and client * Try implementing the notification service endpoint * Revert "Try implementing the notification service endpoint" This does not seem to work because the generated server always responds with application/json, and it is not possible to respond with a different content-type. This reverts commit cf3ce83677649763b8166c4847501c37246dd757. * Revert "Generate server and client" This reverts commit b985c007a0561edbe185adc3b9582e12aa3f072b. * Revert "Add '/notifications' endpoint for subscribing to server-sent events" This reverts commit c5c903329f13dbe4ec096d83b1c8624fd622bef3. * Implement 'GET /notifications' SSE endpoint and logic to detect and notify Devfile changes * Leverage EventSource to subscribe to Server Sent Events Here, this is being used to automatically reload the Devfile in the YAML view whenever the API server notifies of filesystem changes in the Devfile (and related resources). * Add Preference Client to apiserver CLI This is needed to be able to persist Devfiles from the UI to the filesystem * Add E2E test case * fixup! Leverage EventSource to subscribe to Server Sent Events Co-authored-by: Philippe Martin <phmartin@redhat.com> * Limit the round-trips by sending the whole Devfile content in the DevfileUpdated event data Co-authored-by: Philippe Martin <phmartin@redhat.com> * [Cypress] Make sure to wait for APi responses after visiting the home page Co-authored-by: Philippe Martin <phmartin@redhat.com> * Generate static UI * fixup! [Cypress] Make sure to wait for APi responses after visiting the home page --------- Co-authored-by: Philippe Martin <phmartin@redhat.com>
This commit is contained in:
@@ -3,15 +3,13 @@ import { TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_RESOURCES } from "./const
|
||||
describe('devfile editor errors handling', () => {
|
||||
|
||||
it('fails when YAML is not valid', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.setDevfile("wrong yaml content");
|
||||
cy.getByDataCy("yaml-error").should('contain.text', 'error parsing devfile YAML');
|
||||
});
|
||||
|
||||
it('fails when adding a container with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -26,8 +24,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding an image with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -43,8 +40,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding a resource with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -59,8 +55,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding an exec command with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-exec-command.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -79,8 +74,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding an apply command with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-apply-command.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -97,8 +91,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding an image command with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -115,8 +108,7 @@ describe('devfile editor errors handling', () => {
|
||||
});
|
||||
|
||||
it('fails when adding a composite command with an already used name', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,21 @@ import {TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_METADATA, TAB_RESOURCES} f
|
||||
|
||||
describe('devfile editor spec', () => {
|
||||
|
||||
let originalDevfile: string
|
||||
before(() => {
|
||||
cy.readFile('devfile.yaml', null).then(yaml => originalDevfile = (<BufferType> yaml).toString())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cy.readFile('devfile.yaml', null).then(yaml => {
|
||||
if (originalDevfile !== (<BufferType> yaml).toString()) {
|
||||
cy.writeDevfileFile(originalDevfile)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it('displays matadata.name set in YAML', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-metadata-name.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -14,8 +26,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('displays container set in YAML', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||
cy.setDevfile(yaml);
|
||||
});
|
||||
@@ -29,8 +40,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('displays a created container', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_CONTAINERS);
|
||||
cy.getByDataCy('container-name').type('created-container');
|
||||
@@ -43,8 +53,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('displays a created image', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_IMAGES);
|
||||
cy.getByDataCy('image-name').type('created-image');
|
||||
@@ -61,8 +70,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('displays a created resource, with manifest', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_RESOURCES);
|
||||
cy.getByDataCy('resource-name').type('created-resource');
|
||||
@@ -76,8 +84,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('displays a created resource, with uri (default)', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_RESOURCES);
|
||||
cy.getByDataCy('resource-name').type('created-resource');
|
||||
@@ -91,8 +98,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('creates an exec command with a new container', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_COMMANDS);
|
||||
cy.getByDataCy('add').click();
|
||||
@@ -122,8 +128,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('creates an apply image command with a new image', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_COMMANDS);
|
||||
cy.getByDataCy('add').click();
|
||||
@@ -152,8 +157,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('creates an apply resource command with a new resource using manifest', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_COMMANDS);
|
||||
cy.getByDataCy('add').click();
|
||||
@@ -179,8 +183,7 @@ describe('devfile editor spec', () => {
|
||||
});
|
||||
|
||||
it('creates an apply resource command with a new resource using uri (default)', () => {
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.clearDevfile();
|
||||
cy.init();
|
||||
|
||||
cy.selectTab(TAB_COMMANDS);
|
||||
cy.getByDataCy('add').click();
|
||||
@@ -204,4 +207,21 @@ describe('devfile editor spec', () => {
|
||||
.should('contain.text', 'URI')
|
||||
.should('contain.text', '/my/manifest.yaml');
|
||||
});
|
||||
|
||||
it('reloads the Devfile upon changes in the filesystem', () => {
|
||||
cy.init();
|
||||
cy.fixture('input/devfile-new-version.yaml').then(yaml => {
|
||||
cy.writeDevfileFile(yaml);
|
||||
});
|
||||
|
||||
cy.selectTab(TAB_METADATA);
|
||||
cy.getByDataCy("metadata-name").should('have.value', 'my-component');
|
||||
|
||||
cy.selectTab(TAB_CONTAINERS);
|
||||
cy.getByDataCy('container-info').first()
|
||||
.should('contain.text', 'my-cont1')
|
||||
.should('contain.text', 'some-image:latest')
|
||||
.should('contain.text', 'some command')
|
||||
.should('contain.text', 'some arg');
|
||||
});
|
||||
});
|
||||
|
||||
13
ui/cypress/fixtures/input/devfile-new-version.yaml
Normal file
13
ui/cypress/fixtures/input/devfile-new-version.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
schemaVersion: 2.2.0
|
||||
metadata:
|
||||
name: my-component
|
||||
components:
|
||||
- container:
|
||||
args:
|
||||
- some
|
||||
- arg
|
||||
command:
|
||||
- some
|
||||
- command
|
||||
image: some-image:latest
|
||||
name: my-cont1
|
||||
@@ -44,28 +44,47 @@ Cypress.Commands.add('selectTab', (n: number) => {
|
||||
cy.get('div[role=tab]').eq(n).click();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('init', () => {
|
||||
cy.intercept('GET', '/api/v1/devfile').as('init.fetchDevfile');
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('init.applyDevState');
|
||||
cy.visit('http://localhost:4200');
|
||||
cy.wait(['@init.fetchDevfile', '@init.applyDevState']);
|
||||
|
||||
cy.clearDevfile()
|
||||
});
|
||||
|
||||
Cypress.Commands.add('setDevfile', (devfile: string) => {
|
||||
cy.intercept('GET', '/api/v1/devstate/chart').as('getDevStateChart');
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('applyDevState');
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('setDevfile.applyDevState');
|
||||
cy.get('[data-cy="yaml-input"]').type(devfile);
|
||||
cy.get('[data-cy="yaml-save"]').click();
|
||||
cy.wait(['@applyDevState', '@getDevStateChart']);
|
||||
cy.wait(['@setDevfile.applyDevState']);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clearDevfile', () => {
|
||||
cy.intercept('GET', '/api/v1/devstate/chart').as('getDevStateChart');
|
||||
cy.intercept('DELETE', '/api/v1/devstate/devfile').as('clearDevState');
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('applyDevState');
|
||||
cy.intercept('DELETE', '/api/v1/devstate/devfile').as('clearDevfile.clearDevState');
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('clearDevfile.applyDevState');
|
||||
cy.get('[data-cy="yaml-clear"]', { timeout: 60000 }).click();
|
||||
cy.wait(['@clearDevState', '@applyDevState', '@getDevStateChart']);
|
||||
cy.wait(['@clearDevfile.clearDevState', '@clearDevfile.applyDevState']);
|
||||
});
|
||||
|
||||
// writeDevfileFile writes the specified content into the local devfile.yaml file on the filesystem.
|
||||
// Since #6902, doing so sends notification from the server to the client, and makes it reload the Devfile.
|
||||
Cypress.Commands.add('writeDevfileFile', (content: string) => {
|
||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('writeDevfileFile.applyDevState');
|
||||
cy.writeFile('devfile.yaml', content)
|
||||
cy.wait(['@writeDevfileFile.applyDevState']);
|
||||
});
|
||||
|
||||
declare namespace Cypress {
|
||||
interface Chainable {
|
||||
init(): Chainable<void>
|
||||
|
||||
getByDataCy(value: string): Chainable<void>
|
||||
selectTab(n: number): Chainable<void>
|
||||
|
||||
setDevfile(devfile: string): Chainable<void>
|
||||
clearDevfile(): Chainable<void>
|
||||
|
||||
writeDevfileFile(content: string): Chainable<void>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { MermaidService } from './services/mermaid.service';
|
||||
import { StateService } from './services/state.service';
|
||||
import { MatIconRegistry } from "@angular/material/icon";
|
||||
import { OdoapiService } from './services/odoapi.service';
|
||||
import { SseService } from './services/sse.service';
|
||||
import {DevfileContent} from "./api-gen";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -24,6 +26,7 @@ export class AppComponent implements OnInit {
|
||||
private odoApi: OdoapiService,
|
||||
private mermaid: MermaidService,
|
||||
private state: StateService,
|
||||
private sse: SseService,
|
||||
) {
|
||||
this.matIconRegistry.addSvgIcon(
|
||||
`github`,
|
||||
@@ -64,6 +67,13 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.sse.subscribeTo(['DevfileUpdated']).subscribe(event => {
|
||||
let newDevfile: DevfileContent = JSON.parse(event.data)
|
||||
if (newDevfile.content != undefined) {
|
||||
this.onButtonClick(newDevfile.content, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onButtonClick(content: string, save: boolean){
|
||||
|
||||
27
ui/src/app/services/sse.service.ts
Normal file
27
ui/src/app/services/sse.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {Observable} from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SseService {
|
||||
private base = "/api/v1";
|
||||
private evtSource: EventSource
|
||||
|
||||
constructor() {
|
||||
this.evtSource = new EventSource(this.base + "/notifications");
|
||||
}
|
||||
|
||||
subscribeTo(eventTypes: string[]): Observable<any> {
|
||||
return new Observable( (subscriber) => {
|
||||
eventTypes.forEach(eventType => {
|
||||
this.evtSource.addEventListener(eventType, (event) => {
|
||||
subscriber.next(event);
|
||||
});
|
||||
})
|
||||
this.evtSource.onerror = (error) => {
|
||||
subscriber.error(error);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user