test: fix join flow test flakiness

- wait for navigation to complete so we don't accidentally grab an
  element on the page we're navigating away from, leading to a stale
  reference
- wait for focus to move into a selected input field so we don't
  accidentally grab the `validation-message` attached to the old input
  box (the "username" box is selected by default on the join flow page)
This commit is contained in:
Christopher Willis-Ford 2023-09-22 08:43:45 -07:00
parent 8682c4a9f4
commit 06a65d60e6
3 changed files with 80 additions and 51 deletions

View file

@ -5,7 +5,8 @@ const SeleniumHelper = require('./selenium-helpers.js');
const {
clickText,
buildDriver,
findText
findText,
waitUntilDocumentReady
} = new SeleniumHelper();
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
@ -28,14 +29,9 @@ describe('www-integration footer links', () => {
// ==== About Scratch column ====
const pageLoadComplete = () =>
driver.wait(async () => {
return await driver.executeScript('return document.readyState;') === 'complete';
});
test('click About Scratch link', async () => {
await clickText('About Scratch');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/about\/?$/);
@ -43,7 +39,7 @@ describe('www-integration footer links', () => {
test('click For Parents link', async () => {
await clickText('For Parents');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/parents\/?$/);
@ -51,7 +47,7 @@ describe('www-integration footer links', () => {
test('click For Educators link', async () => {
await clickText('For Educators');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/educators\/?$/);
@ -59,7 +55,7 @@ describe('www-integration footer links', () => {
test('click For Developers link', async () => {
await clickText('For Developers');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/developers\/?$/);
@ -69,7 +65,7 @@ describe('www-integration footer links', () => {
test('click Community Guidelines link', async () => {
await clickText('Community Guidelines');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/community_guidelines\/?$/);
@ -77,7 +73,7 @@ describe('www-integration footer links', () => {
test('click Discussion Forums link', async () => {
await clickText('Discussion Forums');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/discuss\/?$/);
@ -85,7 +81,7 @@ describe('www-integration footer links', () => {
test('click Statistics link', async () => {
await clickText('Statistics');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/statistics\/?$/);
@ -95,7 +91,7 @@ describe('www-integration footer links', () => {
test('click Ideas link', async () => {
await clickText('Ideas');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/ideas\/?$/);
@ -103,7 +99,7 @@ describe('www-integration footer links', () => {
test('click FAQ link', async () => {
await clickText('FAQ');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/faq\/?$/);
@ -111,7 +107,7 @@ describe('www-integration footer links', () => {
test('click Download link', async () => {
await clickText('Download');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/download\/?$/);
@ -119,7 +115,7 @@ describe('www-integration footer links', () => {
test('click Contact Us link', async () => {
await clickText('Contact Us');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/contact-us\/?$/);
@ -129,7 +125,7 @@ describe('www-integration footer links', () => {
test('click Terms of Use link', async () => {
await clickText('Terms of Use');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/terms_of_use\/?$/);
@ -137,7 +133,7 @@ describe('www-integration footer links', () => {
test('click Privacy Policy link', async () => {
await clickText('Privacy Policy');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/privacy_policy\/?$/);
@ -145,7 +141,7 @@ describe('www-integration footer links', () => {
test('click Cookies link', async () => {
await clickText('Cookies');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/cookies\/?$/);
@ -158,7 +154,7 @@ describe('www-integration footer links', () => {
test('click DMCA link', async () => {
await clickText('DMCA');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/DMCA\/?$/);
@ -168,7 +164,7 @@ describe('www-integration footer links', () => {
test('click Scratch Conference link', async () => {
await clickText('Scratch Conference');
await pageLoadComplete();
await waitUntilDocumentReady();
let url = await driver.getCurrentUrl();
let pathname = (new URL(url)).pathname;
expect(pathname).toMatch(/^\/scratch-conference\/?$/);

View file

@ -3,9 +3,11 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
findByXpath,
buildDriver,
clickXpath,
buildDriver
findByXpath,
navigate,
waitUntilDocumentReady
} = new SeleniumHelper();
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
@ -18,72 +20,77 @@ let driver;
describe('www-integration join flow', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration join flow');
await driver.get(rootUrl);
});
afterAll(async () => await driver.quit());
beforeEach(async () => {
await driver.get(rootUrl);
await clickXpath('//a[@class="registrationLink"]');
await navigate(rootUrl); // navigate to home page
await clickXpath('//a[@class="registrationLink"]'); // navigate to join page
await waitUntilDocumentReady();
});
test('click Join opens join modal', async () => {
let joinModal = await findByXpath('//div[@class = "join-flow-outer-content"]');
let modalVisible = await joinModal.isDisplayed();
await expect(modalVisible).toBe(true);
expect(modalVisible).toBe(true);
});
test('username validation message appears', async () => {
await clickXpath('//input[contains(@name, "username")]');
const clickedInput = await clickXpath('//input[contains(@name, "username")]');
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', clickedInput));
let message = await findByXpath('//div[contains(@class, "validation-message")]');
let messageText = await message.getText();
await expect(messageText).toEqual('Don\'t use your real name');
expect(messageText).toEqual('Don\'t use your real name');
});
test('password validation message appears', async () => {
await clickXpath('//input[contains(@name, "password")]');
const clickedInput = await clickXpath('//input[contains(@name, "password")]');
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', clickedInput));
let message = await findByXpath('//div[contains(@class, "validation-message")]');
let messageText = await message.getText();
await expect(messageText).toContain('Write it down so you remember.');
expect(messageText).toContain('Write it down so you remember.');
});
test('password validation message appears', async () => {
await clickXpath('//input[contains(@name, "passwordConfirm")]');
test('passwordConfirm validation message appears', async () => {
const clickedInput = await clickXpath('//input[contains(@name, "passwordConfirm")]');
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', clickedInput));
let message = await findByXpath('//div[contains(@class, "validation-message")]');
let messageText = await message.getText();
await expect(messageText).toEqual('Type password again');
expect(messageText).toEqual('Type password again');
});
test('username validation: too short', async () => {
let textInput = await findByXpath('//input[contains(@name, "username")]');
await textInput.click();
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', textInput));
await textInput.sendKeys('ab');
await clickXpath('//div[@class = "join-flow-outer-content"]');
let message = await findByXpath('//div[contains(@class, "validation-error")]');
let messageText = await message.getText();
await expect(messageText).toContain('Must be 3 letters or longer');
expect(messageText).toContain('Must be 3 letters or longer');
});
test('username validation: username taken', async () => {
let textInput = await findByXpath('//input[contains(@name, "username")]');
await textInput.click();
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', textInput));
await textInput.sendKeys(takenUsername);
await clickXpath('//div[@class = "join-flow-outer-content"]');
let message = await findByXpath('//div[contains(@class, "validation-error")]');
let messageText = await message.getText();
await expect(messageText).toContain('Username taken.');
expect(messageText).toContain('Username taken.');
});
test('username validation: bad word', async () => {
let textInput = await findByXpath('//input[contains(@name, "username")]');
await textInput.click();
await driver.wait(() => driver.executeScript('return document.activeElement == arguments[0]', textInput));
// Should be caught by the filter
await textInput.sendKeys('xxxxxxxxx');
await clickXpath('//div[@class = "join-flow-outer-content"]');
let message = await findByXpath('//div[contains(@class, "validation-error")]');
let messageText = await message.getText();
await expect(messageText).toContain('Username not allowed');
expect(messageText).toContain('Username not allowed');
});
});

View file

@ -33,8 +33,10 @@ class SeleniumHelper {
'getLogs',
'getSauceDriver',
'isSignedIn',
'navigate',
'signIn',
'urlMatches',
'waitUntilDocumentReady',
'waitUntilGone'
]);
}
@ -108,6 +110,25 @@ class SeleniumHelper {
return Key[keyName];
}
/**
* Wait until the document is ready (i.e. the document.readyState is 'complete')
* @returns {Promise} A promise that resolves when the document is ready
*/
waitUntilDocumentReady () {
return this.driver.wait(async () =>
await this.driver.executeScript('return document.readyState;') === 'complete'
);
}
/**
* Navigate to the given URL and wait until the document is ready
* @param {string} url The URL to navigate to.
* @returns {Promise} A promise that resolves when the document is ready
*/
navigate (url) {
return this.driver.get(url).then(() => this.waitUntilDocumentReady());
}
findByXpath (xpath, timeoutMessage = `findByXpath timed out for path: ${xpath}`) {
return this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage)
.then(el => (
@ -122,31 +143,35 @@ class SeleniumHelper {
async waitUntilClickable (xpath, allowScrolling = true) {
return await this.driver.wait(async () => {
let elementAtPath = await this.findByXpath(xpath);
const elementAtPath = await this.findByXpath(xpath);
if (!elementAtPath) {
return;
return null;
}
if (allowScrolling) {
await this.driver.executeScript(
const info = await this.driver.executeScript(
`
const info = {};
const element = arguments[0];
const boundingRect = element.getBoundingClientRect();
boundingRect.windowWidth = window.innerWidth;
boundingRect.windowHeight = window.innerHeight;
if (boundingRect.top < 0 || boundingRect.bottom > window.innerHeight ||
boundingRect.left < 0 || boundingRect.right > window.innerWidth)
{
boundingRect.scrollIntoView = true;
element.scrollIntoView({
behavior: 'instant',
block:'nearest',
inline: 'nearest'
});
info.didScroll = true;
}
return info;
`,
elementAtPath
);
if (info.didScroll) {
// try again after the scroll completes
return null;
}
}
const elementAtPoint = await this.driver.executeScript(
@ -160,7 +185,7 @@ class SeleniumHelper {
elementAtPath
);
if (!elementAtPoint) {
return;
return null;
}
// If we ask to click on a button and Selenium finds an image on the button, or vice versa, that's OK.
// It doesn't have to be an exact match.
@ -170,21 +195,22 @@ class SeleniumHelper {
elementAtPoint
);
if (!match) {
return;
return null;
}
if (!await elementAtPath.isDisplayed()) {
return;
return null;
}
if (!await elementAtPath.isEnabled()) {
return;
return null;
}
return elementAtPath;
});
}
async clickXpath (xpath) {
const element = await this.waitUntilClickable(xpath);
async clickXpath (xpath, allowScrolling = true) {
const element = await this.waitUntilClickable(xpath, allowScrolling);
element.click();
return element;
}
clickText (text) {