diff --git a/src/elements/private/I18N/I18N.test.ts b/src/elements/private/I18N/I18N.test.ts index 12f55f2cf..8c108a5a5 100644 --- a/src/elements/private/I18N/I18N.test.ts +++ b/src/elements/private/I18N/I18N.test.ts @@ -1,5 +1,6 @@ -import { expect, fixture } from '@open-wc/testing'; +import { expect, fixture, waitUntil } from '@open-wc/testing'; import { createModel } from '@xstate/test'; +import type { i18n } from 'i18next'; import { createMachine } from 'xstate'; import { I18N } from './I18N'; @@ -68,7 +69,6 @@ function testLang(lang: 'en' | 'fr') { async function testText(element: I18N) { await element.whenReady; - await element.requestUpdate(); const lang = element.lang as 'en' | 'fr'; const ns = element.ns as 'global' | 'custom'; @@ -77,6 +77,17 @@ async function testText(element: I18N) { const value = opts?.value ?? ''; const text = key === '' ? '' : samples.text[lang][ns][key].replace('{{value}}', value); + // `whenReady` only covers the one-time i18next init. Assigning `ns` or `lang` starts another + // load that it does not track, and until that lands `_t` falls back to the global namespace, so + // wait for the bundle this state actually needs before reading the rendered text. + const i18nInstance = (element as unknown as { _i18n: i18n })._i18n; + await waitUntil( + () => !!i18nInstance.getResourceBundle(lang, ns), + `i18next never loaded the ${ns} namespace for ${lang}` + ); + + await element.requestUpdate(); + expect(element.shadowRoot!.textContent).to.equal(text); } diff --git a/src/elements/public/I18n/I18n.test.ts b/src/elements/public/I18n/I18n.test.ts index ab1876550..662c45ed3 100644 --- a/src/elements/public/I18n/I18n.test.ts +++ b/src/elements/public/I18n/I18n.test.ts @@ -1,6 +1,6 @@ import './index'; -import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { FetchEvent } from '../NucleonElement/FetchEvent'; import { I18n } from './I18n'; @@ -67,8 +67,13 @@ describe('I18n', () => { event.preventDefault(); event.respondWith(Promise.resolve(new Response(JSON.stringify(resource)))); - await new Promise(resolve => setTimeout(resolve)); - await element.requestUpdate(); + + // The element re-renders itself when i18next loads a bundle, but reading the response body + // takes more than one task, so wait for the bundle instead of a fixed number of ticks. + await waitUntil( + () => !!I18n.i18next.getResourceBundle('en', 'baz'), + 'i18next never loaded the baz namespace' + ); expect(element).shadowDom.to.equal('bar'); }); @@ -89,8 +94,12 @@ describe('I18n', () => { event.preventDefault(); event.respondWith(Promise.resolve(new Response(JSON.stringify(resource)))); - await new Promise(resolve => setTimeout(resolve)); - await element.requestUpdate(); + + // See the note above: wait for the bundle, not for a fixed number of ticks. + await waitUntil( + () => !!I18n.i18next.getResourceBundle('es', 'shared'), + 'i18next never loaded the es translations' + ); expect(element).shadowDom.to.equal('bar'); }); diff --git a/src/elements/public/PaymentCardEmbed/PaymentCardEmbed.test.ts b/src/elements/public/PaymentCardEmbed/PaymentCardEmbed.test.ts index 63aca9be8..faa9c629c 100644 --- a/src/elements/public/PaymentCardEmbed/PaymentCardEmbed.test.ts +++ b/src/elements/public/PaymentCardEmbed/PaymentCardEmbed.test.ts @@ -22,6 +22,22 @@ class TestElement extends PaymentCardEmbed { customElements.define('test-element', TestElement); +// The Lumo default font stack, verbatim from @vaadin/vaadin-lumo-styles/typography.js. The element +// forwards `getComputedStyle(...).fontFamily`, and browsers rewrite that stack when serializing it +// (current Chrome reports BlinkMacSystemFont as "system-ui"), so resolve it here instead of +// hardcoding one browser's output. +const LUMO_FONT_FAMILY = + '-apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; + +function computedFontFamily(fontFamily: string): string { + const probe = document.createElement('div'); + probe.style.fontFamily = fontFamily; + document.body.appendChild(probe); + const computed = getComputedStyle(probe).fontFamily; + probe.remove(); + return computed; +} + describe('PaymentCardEmbed', () => { it('imports and defines foxy-spinner element', () => { expect(customElements.get('foxy-spinner')).to.exist; @@ -162,8 +178,7 @@ describe('PaymentCardEmbed', () => { '--lumo-size-xs': '26px', '--lumo-border-radius-m': '4px', '--lumo-border-radius-s': '4px', - '--lumo-font-family': - '-apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + '--lumo-font-family': computedFontFamily(LUMO_FONT_FAMILY), '--lumo-font-size-m': '16px', '--lumo-font-size-s': '14px', '--lumo-font-size-xs': '13px', diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 3cb423860..4a1b1683e 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -8,8 +8,26 @@ export default Object.assign({}, webServerConfig, { browsers: [ puppeteerLauncher({ + // Test pages run as concurrent tabs, and a browser produces no frames for a tab that is not + // the visible one. Anything waiting on a frame - `nextFrame()`, `elementUpdated()` on a + // non-Lit element, Vaadin's own internals - then never settles and the test times out. + // Focus emulation makes every page behave as the focused one, so frames keep coming. + createPage: async ({ context }) => { + const page = await context.newPage(); + + try { + const session = await page.target().createCDPSession(); + await session.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + } catch { + // Older or non-Chromium browsers may not support it. Frame-dependent tests can time out + // when that happens, but the run still starts, which beats failing every file. + } + + return page; + }, + launchOptions: { - executablePath: '/usr/bin/chromium', + executablePath: process.env.CHROME_PATH || '/usr/bin/chromium', args: ['--no-sandbox', '--disable-setuid-sandbox'], }, }), @@ -25,6 +43,40 @@ export default Object.assign({}, webServerConfig, { }, }, + // `ResponsiveMixin` sets breakpoint attributes on the element it observes, so the resize it + // reacts to can produce another one in the same delivery cycle. The browser then reports + // "ResizeObserver loop completed with undelivered notifications" - a notice, not an application + // error, since the pending notification is delivered on the next frame. It arrives at + // `window.onerror`, where the test framework counts it against whichever test is running, so + // drop it before that handler sees it. This classic script runs before the module below. + // + // Match the full message, not the "ResizeObserver loop" prefix: "ResizeObserver loop limit + // exceeded" starts the same way but means the browser gave up on a runaway loop, which is a + // real bug and must still fail the test. + testRunnerHtml: testFramework => ` + + +
+ + + + + + + `, + middleware: [ (context, next) => { const url = context.url;