So usually there are really easy ways to avoid mutating your DOM directly from React but unfortunately I'm not in one of those situations right now. I'm using an API that then embeds a lot of content on to my page into a div with an id of dashboard.
I need to manipulate the content on the page and move certain elements around and things. Currently I add an event listener to an element that exists before I call the API, with useEffect() like so:
useEffect(() => {
/*
Some code here...
*/
const setupDashboard = (dashboard) => {
document.querySelector('#run').addEventListener('click', () => {
dashboard.send('dashboard:run')
console.log("Run clicked")
})
console.log(document.querySelector("[data-title='States']"))
}
LookerEmbedSDK.createDashboardWithId(114)
.appendTo('#dashboard')
.on('dashboard:run:start',
/*() => updateState('#dashboard-state', 'Running')*/
)
.on('dashboard:run:complete',
/*() => updateState('#dashboard-state', 'Done')*/
)
.build()
.connect()
.then(setupDashboard)
.catch((error) => {
console.error('An unexpected error occurred', error)
})
});
The piece of code that adds the event handler to the button works, but the next console statement returns null. So it seems like there should be a better way to manipulate the DOM especially since I'm going to completely change it with all my modifications. I'll need to use all sorts of query selectors, deletions, appending and more. What's the best practice for all that.
Edit: to be clear, after the page loads if I run that line in the console it gets the element
I found the answer to my particular situation. In my case, the second query selector doesn't work because the embedded element is in an iframe that represents a second document and is therefore inaccessible.
However, if you're working with react and you need a way to mutate the DOM might I suggest using
ReactDOM.shouldComponentUpdate = () => false
found in this article https://www.reddit.com/r/reactjs/comments/2riuwa/mutating_reacts_dom/
Related
I'm using Cypress 7.7.0 (also tested on 8.0.0), and I'm running into an interesting race condition. I'm testing a page where one of the first interactions that Cypress does is click a button to open a modal. To keep bundle sizes small, I split the modal into its own prefetched webpack chunk. My Cypress test starts with cy.get('#modal-button').click() but this doesn't load the modal because the modal hasn't finished downloading/loading. It does nothing instead (doesn't even throw any errors to the console). In other words, Cypress interacts with the page too quickly. This was also reproduced with manual testing (I clicked on the button super fast after page load). I have tried setting the modal to be preloaded instead, but that didn't work either.
I am able to solve the problem by introducing more delay between page load and button interaction. For example, inserting any Cypress command (even a cy.wait(0)) before I click on the button fixes the solution. Cypress, however, is known for not needing to insert these brittle solutions. Is there a good way to get around this? I'd like to keep the modal in its own chunk.
FYI: I'm using Vue as my front end library and am using a simple defineAsyncComponent(() => import(/* webpackPrefetch: true */ './my-modal.vue')) to load the modal component. I figure that this problem is general to Cypress though.
There's nothing wrong with cy.wait(0).
All you are doing is handing control from the test to the next process in the JS queue, in this case it's the app's startup script which is presumably waiting to add the click handler to the button.
I recently found that this is also needed in a React hooks app to allow the hook to complete it's process. You will likely also come across that in Vue 3, since they have introduced a hook-like feature.
If you want to empirically test that the event handler has arrived, you can use the method given here (modified for click()) - When Can The Test Start?
let appHasStarted
function spyOnAddEventListener (win) {
const addListener = win.EventTarget.prototype.addEventListener
win.EventTarget.prototype.addEventListener = function (name) {
if (name === 'click') {
appHasStarted = true
win.EventTarget.prototype.addEventListener = addListener // restore original listener
}
return addListener.apply(this, arguments)
}
}
function waitForAppStart() {
return new Cypress.Promise((resolve, reject) => {
const isReady = () => {
if (appHasStarted) {
return resolve()
}
setTimeout(isReady, 0) // recheck "appHasStarted" variable
}
isReady()
})
}
it('greets', () => {
cy.visit('app.html', {
onBeforeLoad: spyOnAddEventListener
}).then(waitForAppStart)
cy.get('#modal-button').click()
})
But note setTimeout(isReady, 0) will probably just achieve the same as cy.wait(0) in your app, i.e you don't really need to poll for the event handler, you just need the app to take a breath.
It seems like your problem is that you're already rendering a button before the code backing it is loaded. As you noticed, this isn't only an issue for fast automated bots, but even a "regular" user.
In short, the solution is to not display the button early, but show a loading dialog instead. Cypress allows waiting for a DOM element to be visible with even a timeout option. This is more robust than a brittle random wait.
I ended up going with waiting for the network to be idle, although there were several options available to me.
The cypress function I used to do this was the following which was heavily influenced by this solution for waiting on the network:
Cypress.Commands.add('waitForIdleNetwork', () => {
const idleTimesInit = 3
let idleTimes = idleTimesInit
let resourcesLengthPrevious
cy.window().then(win =>
cy.waitUntil(() => {
const resourcesLoaded = win.performance.getEntriesByType('resource')
if (resourcesLoaded.length === resourcesLengthPrevious) {
idleTimes--
} else {
idleTimes = idleTimesInit
resourcesLengthPrevious = resourcesLoaded.length
}
return !idleTimes
})
)
})
Here are the pros and cons of the solution I went with:
pros: no need to increase bundle size or modify client code when the user will likely never run into this problem
cons: technically still possible to have a race condition where the click event happens after the assets were downloaded, but before they could all execute and render their contents, but very unlikely, not as efficient as waiting on the UI itself for indication of when it is ready
This was the way I chose solve it but the following solutions would have also worked:
creating lightweight placeholder components to take the place of asychronous components while they download and having cypress wait for the actual component to render (e.g. a default modal that just has a spinner being shown while the actual modal is downloaded in the background)
pros: don't have to wait on network resources, avoids all race conditions if implemented properly
cons: have to create a component the user may never see, increases bundle size
"sleeping" an arbitrary amount (although this is brittle) with cy.wait(...)
pros: easy to implement
cons: brittle, not recommended to use this directly by Cypress, will cause linter problems if using eslint-plugin-cypress (you can disable eslint on the line that you use this on, but it "feels ugly" to me (no hate on anyone who programs that way)
I am trying to dynamically map data from one object to another in an $(document).ready() function, the problem is that it works whenever it wants, the console.logs throughout the code are sometimes working, sometimes not.
var newBtnsObj = {};
$(document).ready(() => {
robots.forEach((robot) => {
newBtnsObj[robot.Name] = {};
newBtnsObj[robot.Name].Buttons = {};
console.log(newBtnsObj);
Object.keys(robot.ConfigData).forEach((dataType) => {
if (dataType === DataTypes.IO) {
Object.values(robot.ConfigData[`${dataType}`]).forEach((IOData) => {
if (IOData.length > 0) {
IOData.forEach((s) => {
console.log("HI");
newBtnsObj[robot.Name].Buttons[s.HtmlID] = {
DataType: dataType,
ID: s.ID,
Val: null,
};
});
}
});
} else {
Object.values(robot.ConfigData[`${dataType}`]).forEach((data) => {
console.log("doublehi");
newBtnsObj[robot.Name].Buttons[data.HtmlID] = {
DataType: dataType,
ID: data.ID,
Val: null,
};
});
}
});
});
});
robots is an array that is generated inside a socket.io event whenever someone connects to the page, I am using NodeJS on the backend to emit the "config" data that is used in the creation of the object and it is the first thing that is emitted when someone opens the page. I am opening the page locally and hosting the page and the server locally.
socket.on("config", (config) => {
config.forEach((robot) => {
robots.push(new Robot(robot.Name, robot.configData, robot.Events));
});
The order in which I load the files in the HTML is first the socket file and then the file with the $(document).ready code.
The problem is that newBtnsObj is sometimes created, sometimes not, if I add any new code and refresh the page it works only on the first refresh and then it is again an empty object. But even that doesnt work everytime, I can't even reproduce it 100%.
Now just for completeness I have added the full code, but even if I remove the newBtnsObj creation and leave it only with the console.log("hi")'s I have the same issue, so the issue is not in the object creation itself (if I copy and paste the code in the browser console it works perfectly). So even if its left to this it still has the same issue:
$(document).ready(() => {
robots.forEach((robot) => {
Object.keys(robot.ConfigData).forEach((dataType) => {
if (dataType === DataTypes.IO) {
Object.values(robot.ConfigData[`${dataType}`]).forEach((IOData) => {
if (IOData.length > 0) {
IOData.forEach((s) => {
console.log("HI");
});
}
});
} else {
Object.values(robot.ConfigData[`${dataType}`]).forEach((data) => {
console.log("doublehi");
});
}
});
});
});
Additionally, I thought that the problem may be that, I am trying to use the robots array before it is created from the config received in the initial socket event, so I have tried placing the code within the event itself, so that it executes right after the event is received, and it worked. Obviously that is the issue, how can I solve it? Is the only way to keep the code in the socket event, and if I want to extract it, I have to make another internal event to let the code know that the robots array is ready?
It seems like you programmed yourself a race-condition there. So your jQuery is executed whenever the page is loaded, regardless if the robots object is there or not. But the generation of the object is related to connections and network timing too. So your script only runs fine, if the generation is faster as your page loading.
And now you might ask why it would not work, if you copy the code below the generation itself. That is a race-condition too. Code inside a jQuery ready state will only be executed after page load. If you paste the code inside another script, like the object generation, the ready state may be registered at a time, where the page was already loaded. So it will never be executed, because you registered it to late.
So what you need to do, is to ensure that the code is only executed after the generation is finished. Maybe by using a callback, an event or something else. But for now, you have two related scripts what run whenever they want and nobody waits for each other. ;)
By the way, just a hint: Using $(document).ready(() => {}) is not best practice and out-dated too. There is a shorthand for that. And you should use jQuery instead of $ for calling it, so your scripts will work in noConflict mode too. So insted use jQuery(($) => {}).
I wrote the code to read each li and I warp it up to get each link. And I tried to visit each link in the loop and verify some element which displayed on each page. However, after the new page loads, cypress throws me an error of DOM detach.
https://imgur.com/a/9Pmspkm
I tried to loop and click each link in many ways but I have no luck to make it successfully.
I tried this.
it('test', () => {
cy.visit('https://www.cypress.io/')
cy.get('li.header__NavItem-xi2ch0-6').each(($el, index, list) => {
cy.wrap($el).children().click()
})
})
And this.
it('test', () => {
cy.visit('https://www.cypress.io/')
cy.get('li.header__NavItem-xi2ch0-6').each(($el, index, list) => {
cy.wrap($el).children().and('have.attr', 'href').then((href) => {
cy.log(href)
cy.visit('https://www.cypress.io/' + href)
})
})
})
However, cypress throws me an error of DOM detach.
CypressError: cy.children() failed because this element is detached from the DOM.
...
Cypress requires elements be attached in the DOM to interact with them.
The previous command that ran was:
cy.wrap()
This DOM element likely became detached somewhere between the previous and current command.
Common situations why this happens:
- Your JS framework re-rendered asynchronously
- Your app code reacted to an event firing and removed the element
You typically need to re-query for the element or add 'guards' which delay Cypress from running new commands.
You can use cy.visit with the href attribute, then write an assertion and go back easily xD
cy.get('li').each((item) => {
const title = item.find('h1').first().text();
const link = item.find('a').first();
cy.visit(link.attr('href'));
cy.get('h1').should('have.text', title);
cy.go('back');
});
As an exercise I have to do a little online bike reservation app. This app begins with a header which explains how to use the service. I wanted this tutorial be optional so I wrote a welcome message in HTML and if the user doesn't have a var in his cookies saying he doesn't want to see the tutorial again, the welcome message is replaced by a slider that displays the information.
To achieve that is fetch a JSON file with all the elements I need to build the slider (three divs : the left one with an arrow image inside, the central one where the explanations occur and the right one with another arrow). Furthermore I want to put "click" events on the arrows to display next or previous slide. However, when I do so, only the right arrow event works. I thought of a closure problem since it is the last element to be added to the DOM that keeps its event but tried many things without success. I also tried to add another event to the div that works ("keypress") but only the click seems to work. Can you look at my code give me an hint on what is going on?
Here is the init function of my controller:
init: function() {
var load = this.getCookie();
if(load[0] === ""){
viewHeader.clearCode();
var diapoData = ServiceModule.loadDiapoData("http://localhost/javascript-web-srv/data/diaporama.json");
diapoData.then(
(data) => {
// JSON conversion
modelDiapo.init(data);
// translation into html
controller.initElementHeader(modelDiapo.diapoElt[0]);
controller.hideTuto();
}, (error) => {
console.log('Promise rejected.');
console.log(error);
});
} else {
viewHeader.hideButton();
controller.relaunchTuto();
}
}
There is a closer look at my function translating the JSON elements into HTML and adding events if needed :
initElementHeader: function(data){
data.forEach(element => {
// Creation of the new html element
let newElement = new modelHeader(element);
// render the DOM
viewHeader.init(newElement);
});
}
NewElement is a class creating all I need to insert the HTML, viewHeader.init() updates the DOM with those elements and add events to them if needed.
init: function(objetElt){
// call the render
this.render(objetElt.parentElt, objetElt.element);
// add events
this.addEvent(objetElt);
},
Finally the addEvent function:
addEvent: function(objet){
if(objet.id === "image_fleche_gauche"){
let domEventElt = document.getElementById(objet.id);
domEventElt.addEventListener("click", function(){
// do things
});
}
else if(objet.id === "image_fleche_droite"){
let domEventElt = document.getElementById(objet.id);
domEventElt.addEventListener("click", function(){
// do stuff
});
};
},
I hope being clear enough about my problem. Thank You.
Ok, I found the problem, even if the element was actually created, it somehow stayed in the virtual DOM for some time, when the "getElementById" in "addEvent" was looking for it in the real DOM it didn't find the target and couldn't add the event. This problem didn't occur for the last element since there was nothing else buffering in the virtual DOM.
On conclusion I took out the function adding events out of the forEach loop and created another one after the DOM is updated to add my events.
I'm writing automation tests for a website using Mocha + SeleniumServer + wd.js + chai-as-promised.
The website uses JavaScript for the front-end which seems to refresh the elements on the page when certain action is performed. i.e. Upon selecting an element in a grid, the "next" button is enabled to allow user to move on to the next page. It seems that this changes the reference to the button element resulting in the StaleElementReference error.
describe('1st step', function () {
it('should select an element is grid', function () {
return browser
.waitForElementByCss('#grid', wd.asserters.isDisplayed, 20000)
.elementByCss('#grid .elementToBeSelected')
.click()
.sleep(1000)
.hasElementByCss('#grid elementToBeSelected.active')
.should.eventually.be.true;
});
it('should proceed next step', function () {
return browser
.waitForElementByCss('.btnGrid .btn.nextBtn:not(.disabled)', wd.asserters.isDisplayed, 20000)
.elementByCss('.btnGrid .btn.nextBtn:not(.disabled)')
.click()//Error thrown here
.sleep(2000)
.url()
.should.eventually.become('http://www.somewebsite.com/nextpage');
});
});
With my limited experience with JavaScript, I have tried all that i could think off, but to no avail. So is there anyway I can avoid this StaleElementReference error? Also, the error is only sometimes thrown during execution.
You might want to read some more on the Stale Element Reference exception. From what you are describing, it sounds like you get a reference to an element, do something on the page which then changes/removes the referenced element. When you do something with the variable reference you get this error. The solution really depends on the code you are using to do your tests and your framework for accessing elements. In general, you need to be aware of when you perform an action that changes the page and refetch the element before you access it. You could always refetch an element before you access it, you could refetch all elements that are affected by a page change, and so on...
You code probably looks something like this
WebElement e = driver.findElement(...); // get the element
// do something that changes the page which, in turn, changes e above
e.click(); // throws the StaleElementReference exception
What you probably want is something more like one of these...
Don't fetch the element until you need it
// do something that changes the page which, in turn, changes e above
WebElement e = driver.findElement(...); // get the element
e.click(); // throws the StaleElementReference exception
...or fetch it again right before you need it...
WebElement e = driver.findElement(...); // get the element
// do something that changes the page which, in turn, changes e above
e = driver.findElement(...); // get the element
e.click(); // throws the StaleElementReference exception
I would prefer the first fix... just fetch what you need when you need it. That should be the most efficient way to solve this problem. The second fix might have performance issues because you might be refetching a bunch of elements over and over and either never using them or refetching them 10 times only to reference the element once at the end.