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:
Armel Soro
2023-07-18 17:02:12 +02:00
committed by GitHub
parent 6e725952bd
commit 8c9bcdeb1f
16 changed files with 497 additions and 57 deletions

View File

@@ -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);
});

View File

@@ -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');
});
});

View 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

View File

@@ -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>
}
}

View File

@@ -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){

View 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);
};
});
}
}