Compare commits

..

1 Commits

Author SHA1 Message Date
Bram Kragten 30f29dbeab Add BrowserStack cross-browser/device e2e runs
Layers BrowserStack on top of the local Playwright e2e suites:
- browserstack.yml (Windows Chrome, macOS Firefox, iPad/iPhone WebKit,
  Galaxy S23) driven by the BrowserStack Node SDK and Local tunnel
- :browserstack package scripts and the gated E2E (BrowserStack) CI job
  (runs on manual dispatch or the e2e-browserstack PR label)
- tunnel/iOS-WebKit resilience in the specs (bs-local.com host, single
  shared mobile context, dynamic-import + CDP "Internal error" skips)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:50:27 +02:00
10 changed files with 2999 additions and 88 deletions
+64
View File
@@ -9,7 +9,14 @@ on:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -184,7 +191,64 @@ jobs:
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+6 -1
View File
@@ -24,10 +24,14 @@
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -165,6 +169,7 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
+83 -20
View File
@@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
NAVIGATION_TIMEOUT,
@@ -5,16 +6,47 @@ import {
QUICK_TIMEOUT,
SHELL_TIMEOUT,
appErrors as filterAppErrors,
waitForOrSkip,
} from "./helpers";
// BrowserStack mobile platforms only allow a single browser context per
// session. Using serial mode + a shared page (created once in beforeAll)
// avoids Playwright spinning up a new context for each test.
test.describe.configure({ mode: "serial" });
test.describe("Home Assistant Demo", () => {
// Collect JS errors during each test so we can assert no unexpected crashes.
let pageErrors: Error[] = [];
let sharedPage: Page;
test.beforeEach(async ({ page }) => {
test.beforeAll(async ({ browser }) => {
// BrowserStack mobile pre-creates a single context and page.
// Re-use them instead of calling browser.newContext() which would trigger
// "Only one browser context is allowed" on mobile devices.
const existingContexts = browser.contexts();
const context =
existingContexts.length > 0
? existingContexts[0]
: await browser.newContext();
const existingPages = context.pages();
sharedPage =
existingPages.length > 0 ? existingPages[0] : await context.newPage();
});
test.afterAll(async () => {
// Do not close the context — BrowserStack manages it.
// Just navigate away to a blank page to clean up.
await sharedPage.goto("about:blank").catch(() => {
// Ignore errors if the page/session is already gone.
});
});
test.beforeEach(async () => {
pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err));
await page.goto("/");
sharedPage.removeAllListeners("pageerror");
sharedPage.on("pageerror", (err) => pageErrors.push(err));
await sharedPage.goto("/");
});
function appErrors() {
@@ -23,7 +55,8 @@ test.describe("Home Assistant Demo", () => {
// ── 1. Page loads ──────────────────────────────────────────────────────────
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
test("page loads and ha-demo mounts without JS errors", async () => {
const page = sharedPage;
// The custom element is present in the document
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
@@ -34,13 +67,14 @@ test.describe("Home Assistant Demo", () => {
timeout: NAVIGATION_TIMEOUT,
});
// No unhandled JS exceptions
// No unhandled JS exceptions (excluding infra tunnel errors)
expect(appErrors()).toHaveLength(0);
});
// ── 2. Dashboard renders ───────────────────────────────────────────────────
test("dashboard renders Lovelace cards", async ({ page }) => {
test("dashboard renders Lovelace cards", async () => {
const page = sharedPage;
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
@@ -56,14 +90,22 @@ test.describe("Home Assistant Demo", () => {
"hui-markdown-card",
].join(", ");
await waitForOrSkip(
page,
cardSelector,
"attached",
PANEL_TIMEOUT,
pageErrors
);
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: PANEL_TIMEOUT,
timeout: NAVIGATION_TIMEOUT,
});
});
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
test("sidebar navigation changes the active panel", async ({ page }) => {
test("sidebar navigation changes the active panel", async () => {
const page = sharedPage;
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
@@ -77,16 +119,34 @@ test.describe("Home Assistant Demo", () => {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
await expect(page.locator("ha-sidebar")).toBeVisible({
timeout: SHELL_TIMEOUT,
});
await waitForOrSkip(
page,
"ha-sidebar",
"visible",
SHELL_TIMEOUT,
pageErrors
);
} else {
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await waitForOrSkip(
page,
"ha-sidebar",
"attached",
NAVIGATION_TIMEOUT,
pageErrors
);
}
const candidatePanels = ["map", "logbook", "history", "config"];
const panelSelector = candidatePanels
.map((p) => `#sidebar-panel-${p}`)
.join(", ");
await waitForOrSkip(
page,
panelSelector,
"visible",
SHELL_TIMEOUT,
pageErrors
);
let clicked = false;
for (const panel of candidatePanels) {
@@ -111,9 +171,8 @@ test.describe("Home Assistant Demo", () => {
// ── 4. More info dialog ───────────────────────────────────────────────────
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
test("clicking an entity card opens the more-info dialog", async () => {
const page = sharedPage;
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
@@ -127,9 +186,13 @@ test.describe("Home Assistant Demo", () => {
const cardSelector =
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
await waitForOrSkip(
page,
cardSelector,
"visible",
NAVIGATION_TIMEOUT,
pageErrors
);
await page.locator(cardSelector).first().click();
// The more-info dialog is a top-level custom element appended to the body.
+52 -1
View File
@@ -1,6 +1,7 @@
/**
* Shared helpers and constants for Playwright e2e suites.
*/
import { test, type Page } from "@playwright/test";
// ── Timeouts ────────────────────────────────────────────────────────────────
// Centralised so tweaks don't require search-and-replace across spec files.
@@ -17,11 +18,19 @@ export const NAVIGATION_TIMEOUT = 30_000;
// ── Error filtering ─────────────────────────────────────────────────────────
/**
* BrowserStack tunnel sometimes fails to deliver dynamic-import chunks on
* mobile/iOS combos. These are infrastructure errors, not app bugs.
*/
export const DYNAMIC_IMPORT_ERROR =
/error loading dynamically imported module|Importing a module script failed/i;
/**
* Filter out errors known to be unrelated to the app under test:
* - ResizeObserver loop notifications (browser quirk, harmless)
* - Non-Error rejections (mock data throws plain objects)
* - Browser extension noise
* - Dynamic-import infra failures (see DYNAMIC_IMPORT_ERROR above)
*/
export function appErrors(errors: { message: string }[] | string[]) {
const messages =
@@ -32,6 +41,48 @@ export function appErrors(errors: { message: string }[] | string[]) {
(msg) =>
!msg.includes("ResizeObserver") &&
!msg.includes("Non-Error") &&
!msg.includes("Extension context")
!msg.includes("Extension context") &&
!DYNAMIC_IMPORT_ERROR.test(msg)
);
}
/**
* Wait for `selector` and skip the test (visibly) if the wait fails due to
* a known BrowserStack tunnel infrastructure issue. Re-throws any other error
* so genuine app bugs still surface as test failures.
*
* The BrowserStack iOS WebKit driver occasionally raises a CDP-level
* "Internal error" from `page.locator.waitFor` — these are tunnel/driver
* issues, not app bugs. We narrow the substring match to the specific
* Playwright error message so we don't accidentally swallow real "Internal
* error" exceptions thrown by application code.
*/
export async function waitForOrSkip(
page: Page,
selector: string,
state: "attached" | "visible" = "attached",
timeout = NAVIGATION_TIMEOUT,
pageErrors: Error[] = []
) {
try {
await page.locator(selector).first().waitFor({ state, timeout });
return "ok" as const;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// A dynamic-import network failure (recorded via pageerror) means the
// JS chunk this test depends on never loaded — skip rather than fail.
if (pageErrors.some((e) => DYNAMIC_IMPORT_ERROR.test(e.message))) {
test.skip(true, `dynamic-import infra failure for "${selector}"`);
}
// BrowserStack iOS WebKit raises `locator.waitFor: Internal error` from
// the CDP layer. Match the exact prefix so app-thrown "Internal error"
// strings don't get masked.
if (/locator\.waitFor:.*Internal error/i.test(msg)) {
test.skip(
true,
`BrowserStack iOS WebKit CDP "Internal error" for "${selector}"`
);
}
throw err;
}
}
+8 -2
View File
@@ -1,7 +1,13 @@
import { defineConfig, devices } from "@playwright/test";
const APP_PORT = 8095;
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const APP_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${APP_PORT}`
: `http://localhost:${APP_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const APP_LOCAL_URL = `http://localhost:${APP_PORT}`;
export default defineConfig({
testDir: ".",
@@ -37,7 +43,7 @@ export default defineConfig({
command: process.env.CI
? `npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`
: `./node_modules/.bin/gulp build-e2e-test-app && npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`,
url: APP_BASE_URL,
url: APP_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
+8 -2
View File
@@ -6,7 +6,13 @@ import { defineConfig, devices } from "@playwright/test";
// server instead of starting a new one.
// In CI we serve the pre-built demo/dist on the same port.
const DEMO_PORT = 8090;
const DEMO_BASE_URL = `http://localhost:${DEMO_PORT}`;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const DEMO_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${DEMO_PORT}`
: `http://localhost:${DEMO_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const DEMO_LOCAL_URL = `http://localhost:${DEMO_PORT}`;
export default defineConfig({
testDir: ".",
@@ -48,7 +54,7 @@ export default defineConfig({
command: process.env.CI
? `npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`
: `./node_modules/.bin/gulp build-demo && npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`,
url: DEMO_BASE_URL,
url: DEMO_LOCAL_URL,
// Reuse the develop_demo dev server if it is already running locally.
reuseExistingServer: !process.env.CI,
// Allow up to 5 minutes locally for the demo build + serve startup.
+8 -2
View File
@@ -1,7 +1,13 @@
import { defineConfig, devices } from "@playwright/test";
const GALLERY_PORT = 8100;
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const GALLERY_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${GALLERY_PORT}`
: `http://localhost:${GALLERY_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const GALLERY_LOCAL_URL = `http://localhost:${GALLERY_PORT}`;
export default defineConfig({
testDir: ".",
@@ -37,7 +43,7 @@ export default defineConfig({
command: process.env.CI
? `npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`
: `./node_modules/.bin/gulp build-gallery && npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`,
url: GALLERY_BASE_URL,
url: GALLERY_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
+19 -15
View File
@@ -5,7 +5,7 @@
//
// Usage: node test/e2e/run-suites.mjs <suite> [<suite> ...]
// Where <suite> matches a test:e2e:<suite> script in package.json,
// e.g. "demo", "app", "gallery".
// e.g. "demo", "app", "gallery", "demo:browserstack", etc.
//
// Using ; or running suites independently avoids the && short-circuit problem
// where a failing suite skips the remaining suites and their blob reports.
@@ -30,20 +30,24 @@ for (const suite of suites) {
}
// Collect and merge blob reports regardless of suite outcomes.
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
// (Skipped for browserstack suites — BrowserStack dashboard is the report.)
const isBrowserStack = suites.some((s) => s.includes("browserstack"));
if (!isBrowserStack) {
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
}
if (failures.length) {
process.stderr.write(
+2698 -45
View File
File diff suppressed because it is too large Load Diff