Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spruce up Puppeteer usage to stop timeouts, page conflicts etc #2897

Merged
merged 9 commits into from
Oct 6, 2022

Conversation

colinrotherham
Copy link
Contributor

@colinrotherham colinrotherham commented Oct 5, 2022

This PR has now been split into two

Part 1#2897 Spruce up Puppeteer usage to stop timeouts, page conflicts etc (this one)
Part 2#2861 Use async file operations for helpers

Everything "Puppeteer" runs reliably locally now, with each change is split up by commit so it's clearer what's changed

What's included

  1. Test run locally (concurrent workers) can talk to the wrong Puppeteer page
  2. Browser event handling issues when running "headed" { headless: false }

Some examples:

  • Initial .focus() tests won't pass when tabs/windows are opened in the background
  • Element selectors page.waitForSelector() could have different pages than via page.goto()
  • Reused Puppeteer pages could (still) have JavaScript disabled via page.setJavaScriptEnabled(false)
  • Browsers can throttle timers in background tabs/windows affecting our tests (battery saver too)

More info on Google Chrome background tab timer changes
WHATWG discussion notes: Throttling and clamping of setTimeout and setInterval

What's not included

When running "headed" { headless: false } it's still impossible for multiple windows to have a document.activeElement in focus. Thankfully as headless mode is the default this can be a fix for another day

const activeElement = await page.evaluate(() => document.activeElement.dataset.module)

Perhaps grabbing the page earlier than { waitUntil: 'load' } and before .initAll(), then waiting for the focus event to fire would be more reliable than expecting focus to still be set "right now".

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2897 October 5, 2022 09:41 Inactive
@colinrotherham colinrotherham changed the title Spruce up Puppeteer usage to prevent disconnects, timeouts, page conflicts Spruce up Puppeteer usage to stop timeouts, page conflicts etc Oct 5, 2022
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2897 October 5, 2022 10:00 Inactive
lib/puppeteer-helpers.js Outdated Show resolved Hide resolved
lib/puppeteer-helpers.js Outdated Show resolved Hide resolved
lib/puppeteer-helpers.js Show resolved Hide resolved
@@ -47,7 +49,7 @@ describe('/components/button', () => {
])

const url = await page.url()
expect(url).toBe(baseUrl + href)
expect(url).toBe(`http://localhost:${PORT}${href}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid needing to know the port in this file, wondering if we could match on just the paths?

Think we could use HTMLElement.pathname when evaluating href (rather than reading the href attribute).

Less sure on the best way to get the current page's path out of Puppeteer!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, I didn't feel happy losing the port but I've got your support now 😊

Suggested change
expect(url).toBe(`http://localhost:${PORT}${href}`)
const { pathname } = new URL(url)
expect(pathname).toBe(href)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

lib/puppeteer-helpers.js Outdated Show resolved Hide resolved
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2897 October 5, 2022 15:08 Inactive
Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's neat, really appreciate the goTo and goTo... helpers that avoid dragging a baseUrl in each tests 🙌🏻 Just go a little nitpick on a destructuring assignment and a question on the keyboard throttling.

const { getExamples } = require('../../../../lib/jest-helpers.js')
const { goToComponent, renderAndInitialise } = require('../../../../lib/puppeteer-helpers')

// Detect when browser has been launched headless
const { launch: { headless = true } } = jestPuppeteerConfig
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two levels of destructuring muddies that it's headlessthat ends up as the const. I think it's clearer as const headless = jestPuppeteerConfig.launch.headless.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, I'll change it 👍

Copy link
Contributor Author

@colinrotherham colinrotherham Oct 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal Hope you don't mind a single (with default) destructured property?

Otherwise it defaults to undefined as it's not in the launch config

// Detect when browser has been launched headless
const { headless = true } = jestPuppeteerConfig.launch

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we feel about the Nullish coalescing operator?

// Detect when browser has been launched headless
const headless = jestPuppeteerConfig.launch.headless ?? true;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it was having the two levels of destructuring that I think is too much (not sure if we can lint for that, but that's a separate issue).

Copy link
Contributor Author

@colinrotherham colinrotherham Oct 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we feel about the Nullish coalescing operator?

// Detect when browser has been launched headless
const headless = jestPuppeteerConfig.launch.headless ?? true;

Yeah I love it BUT only where falsy things 0, '', undefined are actually wanted 😮

const headless = jestPuppeteerConfig.launch.headless || true; // true
const headless = jestPuppeteerConfig.launch.headless ?? true; // 0

That said, it's super useful in assignment form with user defaults you don't want to override by accident:

process.env.NODE_ENV ??= 'development'

For me, I think I'm so used to ESM import from + import() that simple destructuring feels neater?

import { example } from './examples.mjs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not my understanding of how ?? works…

node
Welcome to Node.js v16.17.0.
Type ".help" for more information.
> const config = { truthy: true, falsey: false, nullish: null }
undefined
> config.truthy ?? true
true
> config.falsey ?? true
false
> config.nullish ?? true
true
> config.undefined ?? true
true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I used the assignment form ??= above

Depends if we want to guard against config values?

launch: { headless: 0 }
launch: { headless: '' }
launch: { headless: NaN }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm not too concerned about launch.headless being something unexpected as it's in our control, and find the coalesced version easier to understand than the destructured version. But recognise this is probably down to a lack of familiarity with how de-structuring with defaults works and basically personal preference, so this isn't blocking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @36degrees, appreciate the feedback

I suppose it's deciding when something's ready to be added to the code style arsenal?

const hello = example.hello // Yes
const hello = example['hello'] // Yes
const { hello } = example // Maybe?

With all the clever Sass we do, it's nice to have the same artistic licence in JavaScript too 🎩

// Start with three
const more = {
  one: 1,
  two: 2,
  three: 3
}

// Take one out
const { one, ...less } = more

// Then there were two
console.log(less)

// { two: 2, three: 3 }

(That would be a bit too clever)

// in background pages will be aligned, clamped or throttled
const debouncedWaitTime = headless ? 1500 : 2000

// To avoid typing lag, keyup events are also throttled automatically
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The different wait time is pretty clear, but I'm not understanding this explanation about throttling the keyup events. Why do they need to be throttled when the browser is not headless?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember back in the day, using a slower device (old Android phone) and you're typing furiously into a <textarea> with key event listeners, yet no characters are appearing? There was a bit of a lag

With { headless: false } I'm either seeing Chromium automatically debounce keyup events under heavy load (lots of concurrent tests running), background event throttling, or an issue with the Character Count component.

Try it and see? 😭

Open jest-puppeteer.config.js and add launch: { headless: false }

Tests that type in letters too fast will see the screen reader status not change

Might need a better description if it's not clear?

Copy link
Member

@romaricpascal romaricpascal Oct 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, damn. Yeah, I think a better description describing that Chrome messes with keyup events under load would help future us 😄

I'm also wondering if we shouldn't move the component out of using keyboard events for detecting inputs and use the input Event. I believe this'll have to wait the work on browser support though as that event is not supported in IE8 😢

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal @36degrees How's this new explanation?

// When headless, keydown-to-keyup time appears to affect event throttling,
// affecting `lastInputTimestamp` and screen reader status message updates
const keyupWaitTime = headless ? 0 : 50

I'm not 100% on the explanation as perhaps it's related to the whole string of key presses taking less than 500ms, affecting the this.lastInputTimestamp Date.now() check etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal Yeah input is very interesting. It's probably the fact that the older Input Events (including keyup) are technically "cancellable" so for performance maybe they do debounce?

Copy link
Contributor

@36degrees 36degrees left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @colinrotherham 😊 👏🏻

I'm happy that the things I flagged have been addressed, but agree with Romaric's comments so would like to see them resolved before merging.

Suggestion during code review that `page.waitForSelector()` can be used instead
…t runs

Although parallel browser test runs will still push windows from foreground to background, this change ensures that initial `.focus()` events will still fire
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2897 October 5, 2022 21:24 Inactive
Allows more time for slower machines to complete the entire test suite without individual tests timing out
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-2897 October 6, 2022 07:11 Inactive
@colinrotherham colinrotherham merged commit 33d9d57 into main Oct 6, 2022
@colinrotherham colinrotherham deleted the puppeteer-page-object branch October 6, 2022 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Development

Successfully merging this pull request may close these issues.

4 participants