Compare commits

...

10 Commits

Author SHA1 Message Date
Bram Kragten e32dd78f51 Use browserstack node SDK instead 2026-05-11 01:11:54 +02:00
Bram Kragten c3358e0825 use bs-local.com for browserstack 2026-05-10 23:41:08 +02:00
Bram Kragten 764961b28e Update browserstack.capabilities.ts 2026-05-10 23:17:44 +02:00
Bram Kragten 897a33963e fix browserstack ios tests 2026-05-10 23:10:01 +02:00
Bram Kragten 6eadf2ff15 fix gallery tests 2026-05-10 23:03:22 +02:00
Bram Kragten d32f5b6a50 fix 2026-05-08 16:46:51 +02:00
Bram Kragten a21cf5d995 fix browserstack tests 2026-05-08 16:23:00 +02:00
Bram Kragten 9aa6cd4154 fixes 2026-05-08 16:12:25 +02:00
Bram Kragten 2024ce0aef Update e2e.yaml 2026-05-08 16:05:58 +02:00
Bram Kragten 908a518f18 Add e2e playwright tests 2026-05-08 15:57:08 +02:00
38 changed files with 4621 additions and 67 deletions
+276
View File
@@ -0,0 +1,276 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
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: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
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: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
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: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
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: Install Playwright browsers
run: npx playwright install chromium --with-deps
- 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 (local)
run: yarn test:e2e
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
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.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
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 ───────────────────────────
report:
name: Report
needs: [e2e-local, e2e-browserstack]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
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 blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+7
View File
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+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
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21
View File
@@ -266,3 +266,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -228,6 +228,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+12 -1
View File
@@ -20,7 +20,16 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"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: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",
@@ -141,6 +150,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.1",
"@rspack/dev-server": "2.0.1",
@@ -165,6 +175,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.3.0",
"eslint-config-prettier": "10.1.8",
+4
View File
@@ -412,6 +412,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+254
View File
@@ -0,0 +1,254 @@
/**
* E2E tests for the HA test app (port 8095).
*
* Run with:
* yarn test:e2e:app:local
*/
import { test, expect, type Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The test app is built with __DEMO__=true which enables hash-based routing.
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
// Scenario selection uses query params: /?scenario=foo (always at root).
/** Navigate to a panel (hash routing) and wait for app to initialize. */
async function goToPanel(page: Page, path: string) {
// Paths starting with /? are root-level (scenario selection); panel paths
// need to use hash routing (/#/panelname).
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await page.waitForSelector("ha-test", { state: "attached" });
// Wait for the app to finish initialising (hassConnected sets panels)
await page.waitForFunction(() => Boolean((window as any).__mockHass));
}
// ---------------------------------------------------------------------------
// App shell
// ---------------------------------------------------------------------------
test.describe("App shell", () => {
test("page loads and ha-test element mounts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/");
await expect(page.locator("ha-test")).toBeAttached();
expect(errors).toHaveLength(0);
});
test("sidebar renders with expected panels", async ({ page }) => {
await goToPanel(page, "/");
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
for (const urlPath of ["lovelace", "energy", "history"]) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
)
).toBeAttached();
}
// Config has its own special element with id="sidebar-config"
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
)
).toBeAttached();
});
test("admin user sees config panel in sidebar", async ({ page }) => {
await goToPanel(page, "/");
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
)
).toBeAttached();
});
test("non-admin user does NOT see config panel in sidebar", async ({
page,
}) => {
await goToPanel(page, "/?scenario=non-admin");
// Config panel is adminOnly — should not appear for non-admin
const configLink = page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
);
await expect(configLink).not.toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Panel navigation
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
test("navigates to lovelace dashboard", async ({ page }) => {
await goToPanel(page, "/lovelace");
await expect(
page.locator("ha-panel-lovelace, hui-root").first()
).toBeAttached({
timeout: 20_000,
});
});
test("navigates to energy panel", async ({ page }) => {
await goToPanel(page, "/energy");
await expect(
page.locator("ha-panel-energy, energy-view").first()
).toBeAttached({
timeout: 20_000,
});
});
test("navigates to history panel", async ({ page }) => {
await goToPanel(page, "/history");
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({
timeout: 20_000,
});
});
test("navigates to developer-tools panel", async ({ page }) => {
// Since 2026.2 developer-tools is part of the config panel
await goToPanel(page, "/config/developer-tools");
await expect(
page.locator("ha-panel-config, developer-tools-main").first()
).toBeAttached({ timeout: 20_000 });
});
test("navigates to profile panel", async ({ page }) => {
await goToPanel(page, "/profile");
await expect(
page.locator("ha-panel-profile, ha-config-user-profile").first()
).toBeAttached({ timeout: 20_000 });
});
});
// ---------------------------------------------------------------------------
// Lovelace
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
await expect(page.locator("hui-card, hui-tile-card").first()).toBeAttached({
timeout: 20_000,
});
});
test("admin user sees edit button", async ({ page }) => {
await goToPanel(page, "/lovelace");
// The edit FAB / menu button is present for admins
await expect(
page.locator("[data-testid='edit-mode-button'], ha-menu-button")
).toBeAttached({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
test("opens more-info dialog for a light entity", async ({ page }) => {
await goToPanel(page, "/?scenario=light-more-info");
// Wait for ha-test to be ready
await page.waitForFunction(() => Boolean((window as any).__mockHass));
// Navigate to lovelace where tiles should appear
await page.goto("/#/lovelace");
await page.waitForFunction(() => Boolean((window as any).__mockHass));
// Trigger more-info for our known test entity via JS
await page.evaluate(() => {
const hass = (window as any).__mockHass;
// Build the path dynamically to prevent TypeScript from resolving it
// as a local module (it is a runtime URL served by the test app).
const dialogPath = ["/frontend_latest", "ha-more-info-dialog.js"].join(
"/"
);
hass.mockEvent("show-dialog", {
dialogTag: "ha-more-info-dialog",
dialogImport: () =>
import(/* @vite-ignore */ dialogPath).catch(() => null),
dialogParams: { entityId: "light.test_light" },
});
// Use the built-in fire event mechanism
const el = document.querySelector("ha-test") as any;
if (el) {
const event = new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light" },
bubbles: true,
composed: true,
});
el.dispatchEvent(event);
}
});
// The more-info dialog should appear
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: 15_000 });
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
test.describe("Theming", () => {
test("dark theme sets darkMode flag", async ({ page }) => {
await goToPanel(page, "/?scenario=dark-theme");
await expect(page.locator("ha-test")).toBeAttached();
// The dark-theme scenario sets selectedTheme.dark = true, which causes
// _applyTheme() to set themes.darkMode = true on the element.
await page.waitForFunction(
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
{ timeout: 10_000 }
);
});
test("custom theme applies CSS variables", async ({ page }) => {
await goToPanel(page, "/?scenario=custom-theme");
// The custom-theme scenario sets --primary-color to #e91e63
const primaryColor = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue(
"--primary-color"
)
);
expect(primaryColor.trim()).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Config panel
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/config");
await expect(
page.locator("ha-panel-config, ha-config-dashboard").first()
).toBeAttached({ timeout: 25_000 });
// Filter known pre-existing errors from vendor code
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
});
+1
View File
@@ -0,0 +1 @@
import "./ha-test";
+53
View File
@@ -0,0 +1,53 @@
import type { Panels } from "../../../../src/types";
export const e2eTestPanels: Panels = {
lovelace: {
component_name: "lovelace",
icon: "mdi:view-dashboard",
title: "home",
config: { mode: "storage" },
url_path: "lovelace",
},
map: {
component_name: "lovelace",
icon: "mdi:tooltip-account",
title: "map",
config: { mode: "storage" },
url_path: "map",
},
energy: {
component_name: "energy",
icon: "mdi:lightning-bolt",
title: "energy",
config: null,
url_path: "energy",
},
history: {
component_name: "history",
icon: "mdi:chart-box",
title: "history",
config: null,
url_path: "history",
},
config: {
component_name: "config",
icon: "mdi:cog",
title: "config",
config: null,
url_path: "config",
},
profile: {
component_name: "profile",
icon: null,
title: null,
config: null,
url_path: "profile",
},
"developer-tools": {
component_name: "developer-tools",
icon: "mdi:hammer",
title: "developer_tools",
config: null,
url_path: "developer-tools",
},
};
+137
View File
@@ -0,0 +1,137 @@
import { customElement } from "lit/decorators";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
import { navigate } from "../../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../../../src/types";
import { demoSections } from "../../../../demo/src/configs/sections";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockAssist } from "../../../../demo/src/stubs/assist";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import { mockCloud } from "../../../../demo/src/stubs/cloud";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockEvents } from "../../../../demo/src/stubs/events";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
import { mockPersistentNotification } from "../../../../demo/src/stubs/persistent_notification";
import { mockRecorder } from "../../../../demo/src/stubs/recorder";
import { mockSensor } from "../../../../demo/src/stubs/sensor";
import { mockSystemLog } from "../../../../demo/src/stubs/system_log";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockTranslations } from "../../../../demo/src/stubs/translations";
import { mockUpdate } from "../../../../demo/src/stubs/update";
import { e2eTestPanels } from "./ha-test-panels";
import { scenarios } from "./scenarios";
declare global {
interface Window {
__mockHass: MockHomeAssistant;
}
}
@customElement("ha-test")
export class HaTest extends HomeAssistantAppEl {
protected async _initializeHass() {
const scenarioName =
new URLSearchParams(window.location.search).get("scenario") ?? "default";
const scenario = Object.prototype.hasOwnProperty.call(
scenarios,
scenarioName
)
? scenarios[scenarioName as keyof typeof scenarios]
: scenarios.default;
const initial: Partial<MockHomeAssistant> = {
// Use the full panel map (history + config + developer-tools enabled)
panels: e2eTestPanels,
panelUrl: (() => {
const path = window.location.pathname;
const dividerPos = path.indexOf("/", 1);
return dividerPos === -1
? path.substring(1)
: path.substring(1, dividerPos);
})(),
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
this._updateHass(hassUpdate),
};
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
() => this.hass!.localize
);
// Register all stubs
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
mockSensor(hass);
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockEnergy(hass);
mockUpdate(hass);
mockCloud(hass);
mockAssist(hass);
mockAreaRegistry(hass);
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
mockPersistentNotification(hass);
// Load default entities from the sections config
hass.addEntities(energyEntities());
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
}
);
// Apply scenario customisations (may add entities, change user, set theme,
// navigate to a panel, etc.)
await scenario(hass);
// Expose mock handle for Playwright tests to call imperatively
window.__mockHass = hass;
// SPA navigation
document.body.addEventListener(
"click",
(e) => {
const href = isNavigationClick(e);
if (!href) return;
e.preventDefault();
navigate(href);
},
{ capture: true }
);
this.hassConnected();
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-test": HaTest;
}
}
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant E2E Test App</title>
<%= renderTemplate("../../../../../src/html/_header.html.template") %>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
height: 100vh;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<ha-test></ha-test>
<%= renderTemplate("../../../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../../../src/html/_preload_roboto.html.template") %>
<%= renderTemplate("../../../../../src/html/_script_loader.html.template") %>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
// ── Individual scenarios ───────────────────────────────────────────────────
const defaultScenario: Scenario = async (_hass) => {
// Default: admin user, light theme — nothing extra to do, ha-test.ts sets
// everything up already.
};
const nonAdminScenario: Scenario = async (hass) => {
hass.updateHass({
user: {
...hass.user!,
is_admin: false,
is_owner: false,
},
});
};
const darkThemeScenario: Scenario = async (hass) => {
// Force dark mode by setting selectedTheme.dark = true.
// _applyTheme() reads selectedTheme.dark to determine darkMode; setting
// themes.darkMode directly gets overwritten when hassConnected() fires.
hass.updateHass({
selectedTheme: {
theme: hass.selectedTheme?.theme ?? "default",
dark: true,
},
});
};
const customThemeScenario: Scenario = async (hass) => {
hass.mockTheme({
"primary-color": "#e91e63",
"accent-color": "#ff5722",
});
};
const historyPanelScenario: Scenario = async (_hass) => {
// Navigation happens after hassConnected — handled by Playwright via URL
};
const configPanelScenario: Scenario = async (_hass) => {
// Navigation handled by Playwright via URL
};
const lightMoreInfoScenario: Scenario = async (hass) => {
// Make sure we have a light entity available (sections config adds them, but
// this ensures it exists synchronously for tests that load mid-init).
hass.addEntities([
{
entity_id: "light.test_light",
state: "on",
attributes: {
friendly_name: "Test Light",
supported_features: 44,
supported_color_modes: ["brightness", "color_temp", "xy"],
color_mode: "brightness",
brightness: 200,
min_mireds: 153,
max_mireds: 500,
},
},
]);
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
default: defaultScenario,
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"history-panel": historyPanelScenario,
"config-panel": configPanelScenario,
"light-more-info": lightMoreInfoScenario,
};
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Collects blob reports from each suite into a single staging directory so
// `playwright merge-reports` can consume them from one path.
//
// Usage: node test/e2e/collect-blob-reports.mjs
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
const dest = "test/e2e/reports/blob";
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const suite of ["demo", "app", "gallery"]) {
const src = `test/e2e/reports/${suite}`;
let files;
try {
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
} catch {
// Suite report directory doesn't exist (e.g. job was skipped or failed
// before uploading). Skip gracefully.
process.stderr.write(
`Warning: no blob reports found for suite "${suite}" (${src} missing), skipping.\n`
);
continue;
}
for (const file of files) {
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
}
}
+143
View File
@@ -0,0 +1,143 @@
import { expect, test } from "@playwright/test";
test.describe("Home Assistant Demo", () => {
// Collect JS errors during each test so we can assert no unexpected crashes.
let pageErrors: Error[] = [];
test.beforeEach(async ({ page }) => {
pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err));
await page.goto("/");
});
// ── 1. Page loads ──────────────────────────────────────────────────────────
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
// The custom element is present in the document
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
// The launch screen should disappear once the app is ready
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: 30_000,
});
// No unhandled JS exceptions
expect(pageErrors).toHaveLength(0);
});
// ── 2. Dashboard renders ───────────────────────────────────────────────────
test("dashboard renders Lovelace cards", async ({ page }) => {
// Wait for the app shell to be ready
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: 30_000,
});
// Lovelace cards are rendered inside the shadow DOM.
// Playwright pierces shadow roots with CSS selectors automatically.
// We wait for at least one hui-* card element to appear.
const card = page.locator("[class*='hui-']").first();
// Alternatively match by the lovelace view container:
const lovelaceView = page
.locator(
"hui-masonry-view, hui-sections-view, hui-panel-view, hui-sidebar-view"
)
.first();
// One of the two approaches should succeed — wait for whichever is present
await Promise.race([
lovelaceView.waitFor({ state: "attached", timeout: 30_000 }),
card.waitFor({ state: "attached", timeout: 30_000 }),
]);
// At least one card must be visible
const cards = page.locator(
"hui-tile-card, hui-entity-card, hui-glance-card, hui-button-card, hui-markdown-card"
);
await expect(cards.first()).toBeVisible({ timeout: 30_000 });
});
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
test("sidebar navigation changes the active panel", async ({ page }) => {
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: 30_000,
});
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
// inside a modal drawer that is closed by default. Open it first via
// the ha-menu-button in the top app-bar.
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
}
// The sidebar uses ha-list-item-button elements with id="sidebar-panel-{url}"
// Pick "map" as a reliable, always-present demo panel. Fall back to
// "logbook" or "history" if map isn't available.
// Wait for the sidebar itself to render before probing for panels.
await page
.locator("ha-sidebar")
.waitFor({ state: "attached", timeout: 30_000 });
const candidatePanels = ["map", "logbook", "history", "config"];
let clicked = false;
for (const panel of candidatePanels) {
const navItem = page.locator(`#sidebar-panel-${panel}`);
// eslint-disable-next-line no-await-in-loop
const visible = await navItem.isVisible();
if (visible) {
// eslint-disable-next-line no-await-in-loop
await navItem.click();
// eslint-disable-next-line no-await-in-loop
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
timeout: 15_000,
});
clicked = true;
break;
}
}
expect(clicked, "No known sidebar panel was found to click").toBe(true);
expect(pageErrors).toHaveLength(0);
});
// ── 4. More info dialog ───────────────────────────────────────────────────
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: 30_000,
});
// Navigate to the default dashboard (root) in case a previous test
// already navigated away.
await page.goto("/");
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: 30_000,
});
// Tile cards are the most common card type in the demo configs; they are
// clickable and open the more-info dialog.
const tileCard = page.locator("hui-tile-card").first();
await tileCard.waitFor({ state: "visible", timeout: 30_000 });
await tileCard.click();
// The more-info dialog is a top-level custom element appended to the body.
// We verify it is attached, then confirm it rendered by checking the title
// span which is slotted into the light DOM and has real layout dimensions.
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: 15_000 });
// The title is a slotted <span> in the light DOM — visible and has size.
const title = dialog.locator("span.title");
await expect(title).toBeVisible({ timeout: 10_000 });
expect(pageErrors).toHaveLength(0);
});
});
+348
View File
@@ -0,0 +1,348 @@
/**
* E2E tests for the HA gallery (port 8100).
*
* Each component page is tested by navigating to its hash and asserting that
* the demo content renders without JS errors and the page element is visible.
*
* Run with:
* yarn test:e2e:gallery:local
*/
import { test, expect, type Page } from "@playwright/test";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Navigate to a gallery page via hash and wait for it to render. */
async function goToGalleryPage(page: Page, hash: string) {
// First visit to let ha-gallery boot up
await page.goto(`/#${hash}`);
await page.waitForSelector("ha-gallery", { state: "attached" });
// Wait for the demo element to appear in ha-gallery's shadow root.
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
// page-description is only rendered for pages that have a description field,
// so we cannot use it as a universal readiness signal.
const demoTag = `demo-${hash.replace("/", "-")}`;
await page.waitForFunction((tag) => {
const gallery = document.querySelector("ha-gallery") as any;
return gallery?.shadowRoot?.querySelector(tag) != null;
}, demoTag);
}
/** Assert a gallery page loads without console errors.
* Demo elements live inside ha-gallery's shadow root — use >> to pierce it.
*/
async function assertPageLoads(page: Page, hash: string, selector: string) {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToGalleryPage(page, hash);
// Pierce ha-gallery's shadow root with >>
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
timeout: 15_000,
});
const realErrors = errors.filter(
(e) =>
!e.includes("ResizeObserver") &&
!e.includes("Non-Error") &&
!e.includes("Extension context") &&
!e.includes("this.localize is not a function") &&
// Gallery throws plain objects (e.g. from WebSocket/data-fetch) that
// show up as "Object" with no stack — not real JS errors.
e !== "Object" &&
// hui-group-entity-row tries to call .some() on a potentially undefined
// entity_id array from mock state data — pre-existing gallery data issue.
!e.includes("Cannot read properties of undefined (reading 'some')")
);
expect(
realErrors,
`JS errors on ${hash}: ${realErrors.join("; ")}`
).toHaveLength(0);
}
// ---------------------------------------------------------------------------
// Gallery shell
// ---------------------------------------------------------------------------
test.describe("Gallery shell", () => {
test("page loads and ha-gallery mounts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/");
await expect(page.locator("ha-gallery")).toBeAttached({ timeout: 15_000 });
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
test("sidebar renders navigation links", async ({ page }) => {
await page.goto("/");
await page.waitForSelector("ha-gallery", { state: "attached" });
// The gallery drawer sidebar is inside ha-gallery's shadow root
await expect(page.locator("ha-gallery >> mwc-drawer")).toBeAttached({
timeout: 10_000,
});
});
});
// ---------------------------------------------------------------------------
// Component pages
// ---------------------------------------------------------------------------
const componentPages: { name: string; selector: string }[] = [
{ name: "ha-alert", selector: "demo-components-ha-alert" },
{ name: "ha-badge", selector: "demo-components-ha-badge" },
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",
selector: "demo-components-ha-control-circular-slider",
},
{
name: "ha-control-number-buttons",
selector: "demo-components-ha-control-number-buttons",
},
{
name: "ha-control-select-menu",
selector: "demo-components-ha-control-select-menu",
},
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
{
name: "ha-expansion-panel",
selector: "demo-components-ha-expansion-panel",
},
{ name: "ha-faded", selector: "demo-components-ha-faded" },
{ name: "ha-form", selector: "demo-components-ha-form" },
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
{
name: "ha-hs-color-picker",
selector: "demo-components-ha-hs-color-picker",
},
{ name: "ha-input", selector: "demo-components-ha-input" },
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
{ name: "ha-list", selector: "demo-components-ha-list" },
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
{
name: "ha-progress-button",
selector: "demo-components-ha-progress-button",
},
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
{ name: "ha-selector", selector: "demo-components-ha-selector" },
{ name: "ha-slider", selector: "demo-components-ha-slider" },
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
{ name: "ha-switch", selector: "demo-components-ha-switch" },
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
{ name: "ha-tip", selector: "demo-components-ha-tip" },
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
{
name: "ha-adaptive-dialog",
selector: "demo-components-ha-adaptive-dialog",
},
{
name: "ha-adaptive-popover",
selector: "demo-components-ha-adaptive-popover",
},
];
test.describe("Components", () => {
for (const { name, selector } of componentPages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `components/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// More-info pages
// ---------------------------------------------------------------------------
const moreInfoPages: { name: string; selector: string }[] = [
{ name: "light", selector: "demo-more-info-light" },
{ name: "climate", selector: "demo-more-info-climate" },
{ name: "cover", selector: "demo-more-info-cover" },
{ name: "fan", selector: "demo-more-info-fan" },
{ name: "humidifier", selector: "demo-more-info-humidifier" },
{ name: "input-number", selector: "demo-more-info-input-number" },
{ name: "input-text", selector: "demo-more-info-input-text" },
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
{ name: "lock", selector: "demo-more-info-lock" },
{ name: "media-player", selector: "demo-more-info-media-player" },
{ name: "number", selector: "demo-more-info-number" },
{ name: "scene", selector: "demo-more-info-scene" },
{ name: "timer", selector: "demo-more-info-timer" },
{ name: "update", selector: "demo-more-info-update" },
{ name: "vacuum", selector: "demo-more-info-vacuum" },
{ name: "water-heater", selector: "demo-more-info-water-heater" },
];
test.describe("More-info dialogs", () => {
for (const { name, selector } of moreInfoPages) {
test(`more-info ${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `more-info/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Lovelace card pages
// ---------------------------------------------------------------------------
const lovelacePages: { name: string; selector: string }[] = [
{ name: "area-card", selector: "demo-lovelace-area-card" },
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
{
name: "grid-and-stack-card",
selector: "demo-lovelace-grid-and-stack-card",
},
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
{ name: "light-card", selector: "demo-lovelace-light-card" },
{ name: "map-card", selector: "demo-lovelace-map-card" },
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
{
name: "picture-elements-card",
selector: "demo-lovelace-picture-elements-card",
},
{
name: "picture-entity-card",
selector: "demo-lovelace-picture-entity-card",
},
{
name: "picture-glance-card",
selector: "demo-lovelace-picture-glance-card",
},
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
];
test.describe("Lovelace cards", () => {
for (const { name, selector } of lovelacePages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `lovelace/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Specific interaction tests
// ---------------------------------------------------------------------------
test.describe("Component interactions", () => {
test("ha-alert renders all four types", async ({ page }) => {
await goToGalleryPage(page, "components/ha-alert");
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
await expect(demo).toBeAttached({ timeout: 15_000 });
// The demo uses property binding (.alertType) not attribute binding,
// so we verify that multiple ha-alert elements are present.
const alerts = demo.locator("ha-alert");
await expect(alerts.first()).toBeAttached({ timeout: 10_000 });
// There should be at least 4 alerts (one per type)
await expect(alerts)
.toHaveCount(4, { timeout: 10_000 })
.catch(async () => {
// If not exactly 4, just verify there are some (demo may include more)
const count = await alerts.count();
expect(count).toBeGreaterThanOrEqual(4);
});
});
test("ha-button renders primary action button", async ({ page }) => {
await goToGalleryPage(page, "components/ha-button");
const demo = page.locator("ha-gallery >> demo-components-ha-button");
await expect(demo).toBeAttached({ timeout: 15_000 });
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
timeout: 10_000,
});
});
test("ha-control-slider can be found in DOM", async ({ page }) => {
await goToGalleryPage(page, "components/ha-control-slider");
const demo = page.locator(
"ha-gallery >> demo-components-ha-control-slider"
);
await expect(demo).toBeAttached({ timeout: 15_000 });
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
timeout: 10_000,
});
});
test("ha-form renders schema-driven fields", async ({ page }) => {
await goToGalleryPage(page, "components/ha-form");
const demo = page.locator("ha-gallery >> demo-components-ha-form");
await expect(demo).toBeAttached({ timeout: 15_000 });
await expect(demo.locator("ha-form").first()).toBeAttached({
timeout: 10_000,
});
});
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
await goToGalleryPage(page, "components/ha-dialog");
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
await expect(demo).toBeAttached({ timeout: 15_000 });
});
test("tile-card renders entity state", async ({ page }) => {
await goToGalleryPage(page, "lovelace/tile-card");
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
await expect(demo).toBeAttached({ timeout: 15_000 });
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
timeout: 10_000,
});
});
test("more-info light renders controls", async ({ page }) => {
await goToGalleryPage(page, "more-info/light");
const demo = page.locator("ha-gallery >> demo-more-info-light");
await expect(demo).toBeAttached({ timeout: 15_000 });
// Light more-info should contain a brightness or color-temp control
await expect(
demo
.locator("ha-control-slider, ha-more-info-light, more-info-content")
.first()
).toBeAttached({ timeout: 15_000 });
});
test("more-info cover renders position controls", async ({ page }) => {
await goToGalleryPage(page, "more-info/cover");
const demo = page.locator("ha-gallery >> demo-more-info-cover");
await expect(demo).toBeAttached({ timeout: 15_000 });
});
test("ha-gauge renders a gauge element", async ({ page }) => {
await goToGalleryPage(page, "components/ha-gauge");
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
await expect(demo).toBeAttached({ timeout: 15_000 });
// ha-gauge page is markdown-based; gauge elements render in the description area
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
timeout: 10_000,
});
});
test("ha-switch toggles state", async ({ page }) => {
await goToGalleryPage(page, "components/ha-switch");
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
await expect(demo).toBeAttached({ timeout: 15_000 });
const switchEl = demo.locator("ha-switch").first();
await expect(switchEl).toBeAttached({ timeout: 10_000 });
});
});
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from "@playwright/test";
const APP_PORT = 8095;
// 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: ".",
testMatch: "app.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
use: {
baseURL: APP_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
webServer: {
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_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
// Port 8090 matches the `develop_demo` dev server (rspack-dev-server-demo).
// This means running `demo/script/develop_demo` and then `yarn test:e2e:local`
// works out of the box locally — Playwright will reuse the already-running
// server instead of starting a new one.
// In CI we serve the pre-built demo/dist on the same port.
const DEMO_PORT = 8090;
// 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: ".",
testMatch: "demo.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
use: {
baseURL: DEMO_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
// Serve the demo for tests.
// - Locally: if `develop_demo` is already running on port 8090, Playwright
// reuses it. Otherwise it builds demo/dist and serves it.
// Running `develop_demo` first is the recommended local workflow.
// - In CI: demo/dist is downloaded from the build-demo artifact before this
// runs, so we skip the build and go straight to serving.
webServer: {
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_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.
timeout: process.env.CI ? 30_000 : 300_000,
// Run from the repo root so `demo/dist` resolves correctly.
// This config lives at test/e2e/, so two levels up is the repo root.
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from "@playwright/test";
const GALLERY_PORT = 8100;
// 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: ".",
testMatch: "gallery.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
use: {
baseURL: GALLERY_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
webServer: {
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_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
reporter: [
["html", { outputFolder: "reports/combined", open: "never" }],
["json", { outputFile: "reports/combined/results.json" }],
],
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Runs each e2e suite (demo, app, gallery) regardless of individual failures,
// then collects and merges blob reports and exits with a non-zero code if any
// suite failed.
//
// 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", "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.
import { execFileSync } from "child_process";
const suites = process.argv.slice(2);
if (!suites.length) {
process.stderr.write("Usage: run-suites.mjs <suite> [<suite> ...]\n");
process.exit(1);
}
const failures = [];
for (const suite of suites) {
process.stdout.write(`\n--- Running suite: test:e2e:${suite} ---\n`);
try {
execFileSync("yarn", [`test:e2e:${suite}`], { stdio: "inherit" });
} catch {
failures.push(suite);
}
}
// Collect and merge blob reports regardless of suite outcomes.
// (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(
`\nFailed suites: ${failures.map((s) => `test:e2e:${s}`).join(", ")}\n`
);
process.exit(1);
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
// e2e tests run via Playwright's Node.js runner, not the browser bundler
"lib": ["ES2021"],
// Playwright's types use modern module resolution
"moduleResolution": "bundler",
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["./**/*.ts"],
// Clear the exclude from the root tsconfig so we include our own files
"exclude": []
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from "vitest/config";
import { defineConfig, configDefaults } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
exclude: [...configDefaults.exclude, "test/e2e/**"],
environment: "jsdom", // to run in browser-like environment
env: {
TZ: "Etc/UTC",
+3 -1
View File
@@ -157,5 +157,7 @@
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
"lit/directives/ref": ["./node_modules/lit/directives/ref.js"]
}
}
},
// Exclude e2e tests — they have their own tsconfig that adds node types.
"exclude": ["test/e2e"]
}
+2717 -61
View File
File diff suppressed because it is too large Load Diff