test(devtools): revive cypress tests (#61972)

Previously these tests would run automatically when Angular DevTools lived in another repo. These files have continued to live here but have not been running automatically on each PR.

Now, these test files have been revived to run properly with our changes since the repo merge. This is a first step to reviving our e2e testing.

Next steps include writing cypress tests for new features like Injector Graph, Router tree, signals visualizations, etc.

PR Close #61972
This commit is contained in:
AleksanderBodurri 2025-06-15 19:10:52 -04:00 committed by Jessica Janiuk
parent c4dd258658
commit 75d246e03c
18 changed files with 179 additions and 148 deletions

View file

@ -56,6 +56,15 @@ jobs:
run: yarn devtools:test
- name: Test build
run: yarn devtools:build:chrome
- name: Install Cypress
run: yarn global add cypress@14.4.1
- name: Cypress run
uses: cypress-io/github-action@6c143abc292aa835d827652c2ea025d098311070 # v6.10.1
with:
command: cypress run --project ./devtools/cypress
start: yarn bazel run //devtools/src:devserver
wait-on: 'http://localhost:4200'
wait-on-timeout: 300
test:
runs-on: ubuntu-latest-4core

View file

@ -60,6 +60,15 @@ jobs:
run: yarn devtools:test
- name: Test build
run: yarn devtools:build:chrome
- name: Install Cypress
run: yarn global add cypress@14.4.1
- name: Cypress run
uses: cypress-io/github-action@6c143abc292aa835d827652c2ea025d098311070 # v6.10.1
with:
command: cypress run --project ./devtools/cypress
start: yarn bazel run //devtools/src:devserver
wait-on: 'http://localhost:4200'
wait-on-timeout: 300
test:
runs-on: ubuntu-latest-8core

View file

@ -0,0 +1,15 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
module.exports = {
e2e: {
specPattern: 'integration/*.e2e.js',
supportFile: 'support/index.js',
baseUrl: 'http://localhost:4200',
},
};

View file

@ -6,15 +6,13 @@
* found in the LICENSE file at https://angular.dev/license
*/
require('cypress-iframe');
describe('Testing the Todo app Demo', () => {
beforeEach(() => {
cy.visit('/');
});
it('should contain the todos application', () => {
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().contains('Todos');
getBody().contains('About');
getBody().contains('Clear completed');
@ -23,15 +21,15 @@ describe('Testing the Todo app Demo', () => {
});
it('should contain the "Components" tab', () => {
cy.contains('.mat-tab-links', 'Components');
cy.contains('.devtools-nav', 'Components');
});
it('should contain the "Profiler" tab', () => {
cy.contains('.mat-tab-links', 'Profiler');
cy.contains('.devtools-nav', 'Profiler');
});
it('should contain "app-root" and "app-todo-demo" in the component tree', () => {
cy.contains('.tree-node', 'app-root');
cy.contains('.tree-node', 'app-todo-demo');
cy.contains('ng-tree-node', 'app-root');
cy.contains('ng-tree-node', 'app-todo-demo');
});
});

View file

@ -8,7 +8,7 @@
function showComments() {
cy.get('#nav-buttons > button:nth-child(2)').click();
cy.get('#mat-slide-toggle-3 > label > div').click();
cy.get('.cdk-overlay-container mat-slide-toggle label:contains("Show comment nodes")').click();
}
describe('Comment nodes', () => {
@ -17,14 +17,14 @@ describe('Comment nodes', () => {
});
it('should not find any comment nodes by default', () => {
const nodes = cy.$$('.tree-node:contains("#comment")');
const nodes = cy.$$('ng-tree-node:contains("#comment")');
expect(nodes.length).to.eql(0);
});
it('should find comment nodes when the setting is enabled', () => {
showComments();
cy.get('.tree-wrapper')
.find('.tree-node:contains("#comment")')
.find('ng-tree-node:contains("#comment")')
.its('length')
.should('not.eq', 0);
});

View file

@ -12,6 +12,9 @@ describe('Angular Elements', () => {
});
it('should recognize the zippy as an Angular Element', () => {
cy.get('.tree-wrapper').find('.tree-node:contains("app-zippy")').its('length').should('eq', 1);
cy.get('.tree-wrapper')
.find('ng-tree-node:contains("app-zippy")')
.its('length')
.should('eq', 1);
});
});

View file

@ -6,31 +6,29 @@
* found in the LICENSE file at https://angular.dev/license
*/
require('cypress-iframe');
describe('Tracking items from application to component tree', () => {
beforeEach(() => {
cy.visit('/');
});
it('should have only one todo item on start', () => {
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('app-todo').contains('Buy milk');
});
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.its('length')
.should('eq', 2);
});
it('should be able to detect a new todo from user and add it to the tree', () => {
cy.enter('#sample-app')
cy.enterIframe('#sample-app')
.then((getBody) => {
getBody().find('input.new-todo').type('Buy cookies{enter}');
})
.then(() => {
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('app-todo').contains('Buy milk');
getBody().find('app-todo').contains('Build something fun!');
@ -39,7 +37,7 @@ describe('Tracking items from application to component tree', () => {
});
});
cy.get('.tree-wrapper .tree-node:contains("app-todo[TooltipDirective]")').should(
cy.get('.tree-wrapper ng-tree-node:contains("app-todo[TooltipDirective]")').should(
'have.length',
3,
);

View file

@ -11,7 +11,7 @@ function checkSearchedNodesLength(type, length) {
}
function inputSearchText(text) {
cy.get('.filter-input').type(text, {force: true});
cy.get('ng-filter .filter-input').type(text, {force: true});
}
function checkComponentName(name) {
@ -19,7 +19,7 @@ function checkComponentName(name) {
}
function checkEmptyNodes() {
cy.get('.tree-wrapper').find('.matched').should('not.exist');
cy.get('.tree-wrapper').find('.matched-text').should('not.exist');
}
function clickSearchArrows(upwards) {
@ -44,7 +44,7 @@ describe('Search items in component tree', () => {
it('should highlight correct nodes when searching and clear out', () => {
inputSearchText('todo');
checkSearchedNodesLength('.matched', 4);
checkSearchedNodesLength('.matched-text', 4);
// clear search input
inputSearchText('{backspace}{backspace}{backspace}{backspace}');
@ -53,22 +53,23 @@ describe('Search items in component tree', () => {
it('should highlight correct nodes when searching and using arrow keys', () => {
inputSearchText('todo');
checkSearchedNodesLength('.matched', 4);
checkSearchedNodesLength('.matched-text', 4);
checkComponentName('app-todo-demo');
// press down arrow
clickSearchArrows(false);
checkSearchedNodesLength('.selected', 1);
checkComponentName('app-todo-demo');
checkComponentName('app-todos');
// press up arrow
// press down arrow
clickSearchArrows(false);
checkSearchedNodesLength('.selected', 1);
checkComponentName('app-todos');
checkComponentName('app-todo');
// press up arrow
clickSearchArrows(true);
checkSearchedNodesLength('.selected', 1);
checkComponentName('app-todo-demo');
checkComponentName('app-todos');
// clear search input
inputSearchText('{backspace}{backspace}{backspace}{backspace}');
@ -76,7 +77,7 @@ describe('Search items in component tree', () => {
});
it('should select correct node on enter', () => {
inputSearchText('todos{enter}');
inputSearchText('todo{enter}');
checkSearchedNodesLength('.selected', 1);
// should show correct buttons in breadcrumbs
@ -91,7 +92,7 @@ describe('Search items in component tree', () => {
checkComponentName('app-todos');
// should display correct title for properties panel
cy.get('ng-property-view-header').should('have.text', 'app-todos');
cy.get('ng-property-view-header').should('contain.text', 'app-todos');
// should show correct component properties
cy.get('ng-property-view').find('mat-tree-node');

View file

@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
require('cypress-iframe');
describe('node selection', () => {
beforeEach(() => {
cy.visit('/');
@ -15,49 +13,55 @@ describe('node selection', () => {
describe('logic after change detection', () => {
it('should deselect node if it is no longer on the page', () => {
cy.get('.tree-wrapper').get('.tree-node.selected').should('not.exist');
cy.get('.tree-wrapper')
.get('ng-tree-node:contains("app-todo[TooltipDirective]")')
.should('not.have.class', 'selected');
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.first()
.click({force: true});
cy.get('.tree-wrapper').find('.tree-node.selected').its('length').should('eq', 1);
cy.get('.tree-wrapper')
.get('ng-tree-node:contains("app-todo[TooltipDirective]")')
.should('have.class', 'selected');
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('a:contains("About")').click();
});
cy.get('.tree-wrapper').get('.tree-node.selected').should('not.exist');
cy.get('.tree-wrapper')
.get('ng-tree-node:contains("app-todo[TooltipDirective]")')
.should('not.exist');
});
it('should reselect the previously selected node if it is still present', () => {
cy.get('.tree-wrapper').get('.tree-node.selected').should('not.exist');
cy.get('.tree-wrapper').get('ng-tree-node.selected').should('not.exist');
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('input.new-todo').type('Buy cookies{enter}');
});
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.last()
.click({force: true});
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('app-todo:contains("Buy milk")').find('.destroy').click();
});
cy.get('.tree-wrapper').find('.tree-node.selected').its('length').should('eq', 1);
cy.get('.tree-wrapper').find('ng-tree-node.selected').its('length').should('eq', 1);
});
it('should select nodes with same name', () => {
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.first()
.click({force: true});
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.last()
.click({force: true});
@ -74,7 +78,7 @@ describe('node selection', () => {
describe('breadcrumb logic', () => {
it('should overflow when breadcrumb list is long enough', () => {
cy.get('.tree-wrapper')
.find('.tree-node:contains("div[TooltipDirective]")')
.find('ng-tree-node:contains("div[TooltipDirective]")')
.last()
.click({force: true})
.then(() => {
@ -90,7 +94,7 @@ describe('node selection', () => {
it('should scroll right when right scroll button is clicked', () => {
cy.get('.tree-wrapper')
.find('.tree-node:contains("div[TooltipDirective]")')
.find('ng-tree-node:contains("div[TooltipDirective]")')
.last()
.click({force: true})
.then(() => {
@ -106,7 +110,7 @@ describe('node selection', () => {
cy.get('ng-breadcrumbs')
.find('.scroll-button')
.last()
.click()
.click({force: true})
.then(() => {
expect(scrollLeft()).to.be.greaterThan(0);
});
@ -116,7 +120,7 @@ describe('node selection', () => {
it('should scroll left when left scroll button is clicked', () => {
cy.get('.tree-wrapper')
.find('.tree-node:contains("div[TooltipDirective]")')
.find('ng-tree-node:contains("div[TooltipDirective]")')
.last()
.click({force: true})
.then(() => {
@ -132,14 +136,14 @@ describe('node selection', () => {
cy.get('ng-breadcrumbs')
.find('.scroll-button')
.last()
.click()
.click({force: true})
.then(() => {
expect(scrollLeft()).to.be.greaterThan(0);
cy.get('ng-breadcrumbs')
.find('.scroll-button')
.first()
.click()
.click({force: true})
.then(() => {
expect(scrollLeft()).to.eql(0);
});

View file

@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
require('cypress-iframe');
describe('edit properties of directive in the property view tab', () => {
beforeEach(() => {
cy.visit('/');
@ -17,13 +15,13 @@ describe('edit properties of directive in the property view tab', () => {
beforeEach(() => {
// select todo node in component tree
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.first()
.click({force: true});
});
it('should be able to enable editMode', () => {
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('app-todo input.edit').should('not.be.visible');
});
@ -36,61 +34,38 @@ describe('edit properties of directive in the property view tab', () => {
.type('true')
.type('{enter}');
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('app-todo input.edit').should('be.visible');
});
});
});
describe('edit todo property', () => {
beforeEach(() => {
// expand todo state
cy.get('.explorer-panel:contains("app-todo")')
.find('ng-property-view mat-tree-node:contains("todo")')
.click();
describe('edit title property', () => {
beforeEach(() => {
cy.get('.tree-wrapper')
.find('ng-tree-node:contains("app-todos")')
.first()
.click({force: true});
});
it('should change title in app when edited', () => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('#demo-app-title').contains('Angular Todo');
});
it('should change todo label in app when edited', () => {
// check initial todo label
cy.enter('#sample-app').then((getBody) => {
getBody().find('app-todo').contains('Buy milk').its('length').should('eq', 1);
});
// find title variable and run through edit logic
cy.get('.explorer-panel:contains("app-todos")')
.find('ng-property-view mat-tree-node:contains("title")')
.find('ng-property-editor .editor')
.click()
.find('.editor-input')
.clear()
.type('Hello World')
.type('{enter}');
// find label variable and run through edit logic
cy.get('.explorer-panel:contains("app-todo")')
.find('ng-property-view mat-tree-node:contains("label")')
.find('ng-property-editor .editor')
.click()
.find('.editor-input')
.clear()
.type('Buy cookies')
.type('{enter}');
// assert that the page has been updated
cy.enter('#sample-app').then((getBody) => {
getBody().find('app-todo').contains('Buy cookies').its('length').should('eq', 1);
});
});
it('should change todo completed in app when edited', () => {
// check initial todo completed status
cy.enter('#sample-app').then((getBody) => {
getBody().find('app-todo li').not('.completed').its('length').should('eq', 2);
});
// find completed variable and run through edit logic
cy.get('.explorer-panel:contains("app-todo")')
.find('ng-property-view mat-tree-node:contains("completed")')
.find('ng-property-editor .editor')
.click()
.find('.editor-input')
.clear()
.type('true')
.type('{enter}');
// assert that the page has been updated
cy.enter('#sample-app').then((getBody) => {
getBody().find('app-todo li.completed').its('length').should('eq', 1);
});
// assert that the page has been updated
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('#demo-app-title').contains('Hello World');
});
});
});

View file

@ -6,8 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
require('cypress-iframe');
describe('change of the state should reflect in property update', () => {
beforeEach(() => {
cy.visit('/');
@ -15,13 +13,13 @@ describe('change of the state should reflect in property update', () => {
it('should update the property value', () => {
// Complete the todo
cy.enter('#sample-app').then((getBody) => {
cy.enterIframe('#sample-app').then((getBody) => {
getBody().find('input[type="checkbox"].toggle').first().click();
});
// Select the todo item
cy.get('.tree-wrapper')
.find('.tree-node:contains("app-todo[TooltipDirective]")')
.find('ng-tree-node:contains("app-todo[TooltipDirective]")')
.first()
.click({force: true});

View file

@ -16,49 +16,49 @@ describe('Viewing component metadata', () => {
cy.visit('/');
});
describe('viewing TodoComponent', () => {
describe('viewing TodosComponent', () => {
beforeEach(() =>
prepareHeaderExpansionPanelForAssertions('.tree-node:contains("app-todo[TooltipDirective]")'),
prepareHeaderExpansionPanelForAssertions('ng-tree-node:contains("app-todos")'),
);
it('should display view encapsulation', () => {
cy.contains('.meta-data-container .mat-button:first', 'View Encapsulation: Emulated');
cy.contains('.meta-data-container', 'View Encapsulation: None');
});
it('should display change detection strategy', () => {
cy.contains('.meta-data-container .mat-button:last', 'Change Detection Strategy: OnPush');
cy.contains('.meta-data-container', 'Change Detection Strategy: Default');
});
});
describe('viewing DemoAppComponent', () => {
beforeEach(() =>
prepareHeaderExpansionPanelForAssertions('.tree-node:contains("app-demo-component")'),
prepareHeaderExpansionPanelForAssertions('ng-tree-node:contains("app-demo-component")'),
);
it('should display view encapsulation', () => {
cy.contains('.meta-data-container .mat-button:first', 'View Encapsulation: None');
cy.contains('.meta-data-container', 'View Encapsulation: None');
});
it('should display change detection strategy', () => {
cy.contains('.meta-data-container .mat-button:last', 'Change Detection Strategy: Default');
cy.contains('.meta-data-container', 'Change Detection Strategy: Default');
});
it('should display correct set of inputs', () => {
cy.contains('.cy-inputs', 'Inputs');
cy.contains('.cy-inputs mat-tree-node:first span:first', 'inputOne');
cy.contains('.cy-inputs mat-tree-node:last span:first', 'inputTwo');
cy.contains('.mat-accordion-content#Inputs', 'Inputs');
cy.contains('.mat-accordion-content#Inputs mat-tree-node:first span:first', 'inputOne');
cy.contains('.mat-accordion-content#Inputs mat-tree-node:last span:first', 'inputTwo');
});
it('should display correct set of outputs', () => {
cy.contains('.cy-outputs', 'Outputs');
cy.contains('.cy-outputs mat-tree-node:first span:first', 'outputOne');
cy.contains('.cy-outputs mat-tree-node:last span:first', 'outputTwo');
cy.contains('.mat-accordion-content#Outputs', 'Outputs');
cy.contains('.mat-accordion-content#Outputs mat-tree-node:first span:first', 'outputOne');
cy.contains('.mat-accordion-content#Outputs mat-tree-node:last span:first', 'outputTwo');
});
it('should display correct set of properties', () => {
cy.contains('.cy-properties', 'Properties');
cy.contains('.cy-properties mat-tree-node:first span:first', 'elementRef');
cy.contains('.cy-properties mat-tree-node:last span:first', 'zippy');
cy.contains('.mat-accordion-content#Properties', 'Properties');
cy.contains('.mat-accordion-content#Properties mat-tree-node:first span:first', 'elementRef');
cy.contains('.mat-accordion-content#Properties mat-tree-node:last span:first', 'zippy');
});
});
});

View file

@ -6,28 +6,25 @@
* found in the LICENSE file at https://angular.dev/license
*/
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
/**
* Selects an Iframe and returns its body when it's done loading.
* @param {string} selector - The selector for the iframe.
* @returns {Function} A function that returns the wrapped body of the iframe.
*/
function enterIframe(selector) {
return cy.get(selector, {log: false}).then({timeout: 30000}, async (frame) => {
const contentWindow = frame.prop('contentWindow');
while (
contentWindow.location.toString() === 'about:blank' ||
contentWindow.document.readyState !== 'complete'
) {
await new Promise((resolve) => setTimeout(resolve));
}
// return the body of the iframe wrapped in cypress
return () => cy.wrap(contentWindow.document.body);
});
}
Cypress.Commands.add('enterIframe', enterIframe);

View file

@ -23,7 +23,7 @@
</div>
}
@for (panel of panels(); track $index) {
<div class="mat-accordion-content" cdkDrag>
<div class="mat-accordion-content" [id]="panel.title()" cdkDrag>
@if (panel.controls().dataSource.data.length > 0) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header collapsedHeight="28px" expandedHeight="28px">

View file

@ -2,4 +2,5 @@
<app-zippy [title]="getTitle()">This is my content</app-zippy>
<app-heavy></app-heavy>
<div #elementReference>HTMLElement</div>
<div *appStructural>Test Structural Directive</div>
<app-sample-properties></app-sample-properties>

View file

@ -10,11 +10,15 @@ import {
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
Directive,
ElementRef,
inject,
input,
output,
signal,
TemplateRef,
viewChild,
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
@ -23,13 +27,30 @@ import {HeavyComponent} from './heavy.component';
import {SamplePropertiesComponent} from './sample-properties.component';
import {RouterOutlet} from '@angular/router';
// structual directive example
@Directive({
selector: '[appStructural]',
host: {
'[class.app-structural]': 'true',
},
})
export class StructuralDirective {
templateRef = inject(TemplateRef);
viewContainerRef = inject(ViewContainerRef);
ngOnInit() {
// Example of using the structural directive
this.viewContainerRef.createEmbeddedView(this.templateRef);
}
}
@Component({
selector: 'app-demo-component',
templateUrl: './demo-app.component.html',
styleUrls: ['./demo-app.component.scss'],
encapsulation: ViewEncapsulation.None,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [HeavyComponent, SamplePropertiesComponent, RouterOutlet],
imports: [StructuralDirective, HeavyComponent, SamplePropertiesComponent, RouterOutlet],
})
export class DemoAppComponent {
readonly zippy = viewChild(ZippyComponent);

View file

@ -4,7 +4,7 @@
<p>{{ 'Sample text processed by a pipe' | sample }}</p>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<h1 id="demo-app-title">{{title}}</h1>
<input
(keydown.enter)="addTodo(input)"
#input

View file

@ -28,6 +28,8 @@ const fib = (n: number): number => {
imports: [RouterLink, TodoComponent, TooltipDirective, SamplePipe, TodosFilter],
})
export class TodosComponent implements OnInit, OnDestroy {
title = 'Angular Todo';
todos: Todo[] = [
{
label: 'Buy milk',