mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-14 18:37:37 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f29dbeab | ||
|
|
2c8d6c1a02 |
@@ -0,0 +1,308 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||||
|
# Chromium; anything longer is almost certainly an install or webServer
|
||||||
|
# hang.
|
||||||
|
timeout-minutes: 30
|
||||||
|
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
|
||||||
|
|
||||||
|
# Cache the downloaded browser build keyed on the pinned Playwright
|
||||||
|
# version (yarn.lock), so re-runs skip the ~170 MB download.
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: yarn playwright install --with-deps chromium
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
- 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
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
#
|
||||||
|
# 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]
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -54,8 +54,16 @@ src/cast/dev_const.ts
|
|||||||
# test coverage
|
# test coverage
|
||||||
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
|
# AI tooling
|
||||||
.claude
|
.claude
|
||||||
.cursor
|
.cursor
|
||||||
.opencode
|
.opencode
|
||||||
|
.serena
|
||||||
|
|
||||||
test/benchmarks/results/
|
test/benchmarks/results/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* global require, module, __dirname, process */
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const env = require("./env.cjs");
|
const env = require("./env.cjs");
|
||||||
const paths = require("./paths.cjs");
|
const paths = require("./paths.cjs");
|
||||||
@@ -321,4 +320,22 @@ module.exports.config = {
|
|||||||
isLandingPageBuild: true,
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
import globals from "globals";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import rootConfig from "../eslint.config.mjs";
|
import rootConfig from "../eslint.config.mjs";
|
||||||
|
|
||||||
export default tseslint.config(...rootConfig, {
|
export default tseslint.config(...rootConfig, {
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
"import-x/no-extraneous-dependencies": "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.
|
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||||
// The original helper evaluates `Function('return require("...")')()`
|
// The original helper evaluates `Function('return require("...")')()`
|
||||||
// when it detects a Node environment, which causes a runtime
|
// when it detects a Node environment, which causes a runtime
|
||||||
|
|||||||
@@ -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])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* global process */
|
|
||||||
// Tasks to generate entry HTML
|
// Tasks to generate entry HTML
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -268,3 +267,24 @@ gulp.task(
|
|||||||
paths.landingPage_output_es5
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
|
|||||||
copyFonts(paths.landingPage_output_static);
|
copyFonts(paths.landingPage_output_static);
|
||||||
copyTranslations(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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./clean.js";
|
|||||||
import "./compress.js";
|
import "./compress.js";
|
||||||
import "./demo.js";
|
import "./demo.js";
|
||||||
import "./download-translations.js";
|
import "./download-translations.js";
|
||||||
|
import "./e2e-test-app.js";
|
||||||
import "./entry-html.js";
|
import "./entry-html.js";
|
||||||
import "./fetch-nightly-translations.js";
|
import "./fetch-nightly-translations.js";
|
||||||
import "./gallery.js";
|
import "./gallery.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Gulp task to generate third-party license notices.
|
// Gulp task to generate third-party license notices.
|
||||||
|
|
||||||
import { readFile, access } from "fs/promises";
|
import { readFile, access, readdir } from "fs/promises";
|
||||||
import { generateLicenseFile } from "generate-license-file";
|
import { generateLicenseFile } from "generate-license-file";
|
||||||
import gulp from "gulp";
|
import gulp from "gulp";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
|
|||||||
"third-party-licenses.txt"
|
"third-party-licenses.txt"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
|
||||||
|
|
||||||
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
||||||
// redistributed alongside the compiled output per Apache License §4(d).
|
// redistributed alongside the compiled output per Apache License §4(d).
|
||||||
const NOTICE_FILES = [
|
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
|
||||||
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
// Some packages need a manual license override (e.g. they ship multiple
|
||||||
// We use the MIT license since that covers the bundled code.
|
// license files and we must pick the right one for the bundled code).
|
||||||
//
|
//
|
||||||
// Each entry is pinned to a specific version. If a package is updated,
|
// Each entry is pinned to a specific version. If a package is updated,
|
||||||
// this list must be reviewed and the version updated after verifying
|
// this list must be reviewed and the version updated after verifying
|
||||||
// that the new version's license still matches. The build will fail
|
// that the new version's license still matches. The build will fail if
|
||||||
// if the installed version does not match the pinned version.
|
// the pinned version is no longer installed.
|
||||||
const LICENSE_OVERRIDES = [
|
const LICENSE_OVERRIDES = [
|
||||||
{
|
{
|
||||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||||
// We use the MIT license since that covers the bundled code.
|
// We use the MIT license since that covers the bundled code.
|
||||||
packageName: "type-fest",
|
packageName: "type-fest",
|
||||||
version: "5.7.0",
|
version: "5.7.0",
|
||||||
licensePath: path.resolve(
|
licenseFile: "license-mit",
|
||||||
paths.root_dir,
|
|
||||||
"node_modules/type-fest/license-mit"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Locate the directory of an installed package matching an exact version.
|
||||||
|
//
|
||||||
|
// The copy we care about may be hoisted to the top-level node_modules or
|
||||||
|
// nested under a dependency when a different version occupies the hoisted
|
||||||
|
// slot (e.g. a build-only dependency pulling in an older release). Searching
|
||||||
|
// both keeps this check independent of yarn's hoisting decisions, which can
|
||||||
|
// shift when unrelated dependencies are added.
|
||||||
|
async function findPackageDir(packageName, version) {
|
||||||
|
const candidateDirs = [path.join(NODE_MODULES, packageName)];
|
||||||
|
|
||||||
|
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
|
||||||
|
// node_modules/@scope/<dep>/node_modules/<pkg>.
|
||||||
|
let topLevel = [];
|
||||||
|
try {
|
||||||
|
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
// node_modules unreadable — fall back to the hoisted candidate only.
|
||||||
|
}
|
||||||
|
for (const entry of topLevel) {
|
||||||
|
if (!entry.isDirectory() || entry.name === packageName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name.startsWith("@")) {
|
||||||
|
const scopeDir = path.join(NODE_MODULES, entry.name);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
|
||||||
|
() => []
|
||||||
|
);
|
||||||
|
for (const dep of scoped) {
|
||||||
|
if (dep.isDirectory()) {
|
||||||
|
candidateDirs.push(
|
||||||
|
path.join(scopeDir, dep.name, "node_modules", packageName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidateDirs.push(
|
||||||
|
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of candidateDirs) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
|
||||||
|
.then(JSON.parse)
|
||||||
|
.catch(() => null);
|
||||||
|
if (pkg?.version === version) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
gulp.task("gen-licenses", async () => {
|
gulp.task("gen-licenses", async () => {
|
||||||
const licenseOverrides = {};
|
const licenseOverrides = {};
|
||||||
|
|
||||||
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
|
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
|
||||||
const pkgJsonPath = path.resolve(
|
// eslint-disable-next-line no-await-in-loop
|
||||||
paths.root_dir,
|
const packageDir = await findPackageDir(packageName, version);
|
||||||
`node_modules/${packageName}/package.json`
|
|
||||||
);
|
|
||||||
|
|
||||||
let packageJSON;
|
if (!packageDir) {
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
|
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packageJSON.version !== version) {
|
|
||||||
throw new Error(
|
|
||||||
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
|
|
||||||
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const licensePath = path.join(packageDir, licenseFile);
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await access(licensePath);
|
await access(licensePath);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createDemoConfig,
|
createDemoConfig,
|
||||||
createGalleryConfig,
|
createGalleryConfig,
|
||||||
createLandingPageConfig,
|
createLandingPageConfig,
|
||||||
|
createE2eTestAppConfig,
|
||||||
} from "../rspack.cjs";
|
} from "../rspack.cjs";
|
||||||
|
|
||||||
const bothBuilds = (createConfigFunc, params) => [
|
const bothBuilds = (createConfigFunc, params) => [
|
||||||
@@ -231,3 +232,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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -50,4 +50,15 @@ module.exports = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
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"
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* global require, module, __dirname */
|
|
||||||
const { existsSync } = require("fs");
|
const { existsSync } = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const rspack = require("@rspack/core");
|
const rspack = require("@rspack/core");
|
||||||
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
|||||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||||
|
|
||||||
|
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||||
|
createRspackConfig(
|
||||||
|
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createAppConfig,
|
createAppConfig,
|
||||||
createDemoConfig,
|
createDemoConfig,
|
||||||
@@ -345,4 +349,5 @@ module.exports = {
|
|||||||
createGalleryConfig,
|
createGalleryConfig,
|
||||||
createRspackConfig,
|
createRspackConfig,
|
||||||
createLandingPageConfig,
|
createLandingPageConfig,
|
||||||
|
createE2eTestAppConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||||
|
|
||||||
|
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||||
|
hass.mockWS("update/list", () => []);
|
||||||
|
};
|
||||||
@@ -234,6 +234,12 @@ export default tseslint.config(
|
|||||||
globals: globals.serviceworker,
|
globals: globals.serviceworker,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["test/e2e/*.mjs"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
html,
|
html,
|
||||||
|
|||||||
+13
-1
@@ -22,7 +22,16 @@
|
|||||||
"postpack": "pinst --enable",
|
"postpack": "pinst --enable",
|
||||||
"test": "vitest run --config test/vitest.config.ts",
|
"test": "vitest run --config test/vitest.config.ts",
|
||||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
"test:bench": "vitest bench --run --config test/vitest.bench.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 <[email protected]> (http://paulusschoutsen.nl)",
|
"author": "Paulus Schoutsen <[email protected]> (http://paulusschoutsen.nl)",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -137,9 +146,11 @@
|
|||||||
"@octokit/auth-oauth-device": "8.0.3",
|
"@octokit/auth-oauth-device": "8.0.3",
|
||||||
"@octokit/plugin-retry": "8.1.0",
|
"@octokit/plugin-retry": "8.1.0",
|
||||||
"@octokit/rest": "22.0.1",
|
"@octokit/rest": "22.0.1",
|
||||||
|
"@playwright/test": "1.60.0",
|
||||||
"@rsdoctor/rspack-plugin": "1.5.13",
|
"@rsdoctor/rspack-plugin": "1.5.13",
|
||||||
"@rspack/core": "2.0.8",
|
"@rspack/core": "2.0.8",
|
||||||
"@rspack/dev-server": "2.0.3",
|
"@rspack/dev-server": "2.0.3",
|
||||||
|
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||||
"@types/chromecast-caf-receiver": "6.0.26",
|
"@types/chromecast-caf-receiver": "6.0.26",
|
||||||
"@types/chromecast-caf-sender": "1.0.11",
|
"@types/chromecast-caf-sender": "1.0.11",
|
||||||
"@types/color-name": "2.0.0",
|
"@types/color-name": "2.0.0",
|
||||||
@@ -158,6 +169,7 @@
|
|||||||
"babel-loader": "10.1.1",
|
"babel-loader": "10.1.1",
|
||||||
"babel-plugin-template-html-minifier": "4.1.0",
|
"babel-plugin-template-html-minifier": "4.1.0",
|
||||||
"browserslist-useragent-regexp": "4.1.4",
|
"browserslist-useragent-regexp": "4.1.4",
|
||||||
|
"browserstack-node-sdk": "1.53.2",
|
||||||
"del": "8.0.1",
|
"del": "8.0.1",
|
||||||
"eslint": "10.5.0",
|
"eslint": "10.5.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
|||||||
@@ -457,6 +457,10 @@ export const provideHass = (
|
|||||||
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
formatEntityName: (stateObj, type) =>
|
||||||
|
typeof type === "string"
|
||||||
|
? type
|
||||||
|
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||||
...overrideData,
|
...overrideData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests for the HA test app (port 8095).
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* yarn test:e2e:app
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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, "/lovelace");
|
||||||
|
|
||||||
|
// 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("non-admin user does NOT see config panel in sidebar", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Navigate to a panel route so the sidebar actually renders, then apply
|
||||||
|
// the non-admin scenario via query param.
|
||||||
|
await goToPanel(page, "/?scenario=non-admin#/lovelace");
|
||||||
|
|
||||||
|
// Wait for the sidebar to mount before asserting on its contents.
|
||||||
|
await expect(
|
||||||
|
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
|
||||||
|
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||||
|
|
||||||
|
// 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: PANEL_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates to energy panel", async ({ page }) => {
|
||||||
|
await goToPanel(page, "/energy");
|
||||||
|
await expect(
|
||||||
|
page.locator("ha-panel-energy, energy-view").first()
|
||||||
|
).toBeAttached({
|
||||||
|
timeout: PANEL_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates to history panel", async ({ page }) => {
|
||||||
|
await goToPanel(page, "/history");
|
||||||
|
await expect(
|
||||||
|
page.locator("ha-panel-history, history-panel").first()
|
||||||
|
).toBeAttached({
|
||||||
|
timeout: PANEL_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: PANEL_TIMEOUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: PANEL_TIMEOUT });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: PANEL_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: QUICK_TIMEOUT });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// More-info dialog (light)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Light more-info dialog", () => {
|
||||||
|
test("opens more-info dialog for a light entity", async ({ page }) => {
|
||||||
|
// The light-more-info scenario seeds light.test_light synchronously.
|
||||||
|
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
|
||||||
|
|
||||||
|
// Fire the standard hass-more-info event from the app root. The HA shell
|
||||||
|
// listens for this and opens ha-more-info-dialog via its dialog manager.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector("ha-test");
|
||||||
|
el?.dispatchEvent(
|
||||||
|
new CustomEvent("hass-more-info", {
|
||||||
|
detail: { entityId: "light.test_light" },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = page.locator("ha-more-info-dialog");
|
||||||
|
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||||
|
|
||||||
|
// Confirm it actually rendered our entity, not a generic empty dialog.
|
||||||
|
await expect(dialog.locator("span.title")).toContainText("Test Light", {
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Theming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe("Theming", () => {
|
||||||
|
test("dark theme sets darkMode flag", async ({ page }) => {
|
||||||
|
await goToPanel(page, "/?scenario=dark-theme#/lovelace");
|
||||||
|
|
||||||
|
// 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: QUICK_TIMEOUT }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom theme applies CSS variables", async ({ page }) => {
|
||||||
|
await goToPanel(page, "/?scenario=custom-theme#/lovelace");
|
||||||
|
|
||||||
|
// The custom-theme scenario sets --primary-color to #e91e63. Wait until
|
||||||
|
// _applyTheme has propagated the value to <html> before reading it — the
|
||||||
|
// scenario fires before hassConnected, but the variable lands on :root in
|
||||||
|
// the same tick mockTheme is called.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--primary-color")
|
||||||
|
.trim() !== "",
|
||||||
|
{ timeout: QUICK_TIMEOUT }
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryColor = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--primary-color")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
// Compare normalised — some browsers serialise as rgb().
|
||||||
|
expect(primaryColor.toLowerCase()).toMatch(
|
||||||
|
/#e91e63|rgb\(233,\s*30,\s*99\)/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: PANEL_TIMEOUT + 5_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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "./ha-test";
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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 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,
|
||||||
|
"light-more-info": lightMoreInfoScenario,
|
||||||
|
};
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
NAVIGATION_TIMEOUT,
|
||||||
|
PANEL_TIMEOUT,
|
||||||
|
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.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 = [];
|
||||||
|
sharedPage.removeAllListeners("pageerror");
|
||||||
|
sharedPage.on("pageerror", (err) => pageErrors.push(err));
|
||||||
|
await sharedPage.goto("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
function appErrors() {
|
||||||
|
return filterAppErrors(pageErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Page loads ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The launch screen should disappear once the app is ready
|
||||||
|
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No unhandled JS exceptions (excluding infra tunnel errors)
|
||||||
|
expect(appErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("dashboard renders Lovelace cards", async () => {
|
||||||
|
const page = sharedPage;
|
||||||
|
await expect(page.locator("ha-demo")).toBeAttached({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardSelector = [
|
||||||
|
"hui-tile-card",
|
||||||
|
"hui-entity-card",
|
||||||
|
"hui-glance-card",
|
||||||
|
"hui-button-card",
|
||||||
|
"hui-markdown-card",
|
||||||
|
].join(", ");
|
||||||
|
|
||||||
|
await waitForOrSkip(
|
||||||
|
page,
|
||||||
|
cardSelector,
|
||||||
|
"attached",
|
||||||
|
PANEL_TIMEOUT,
|
||||||
|
pageErrors
|
||||||
|
);
|
||||||
|
await expect(page.locator(cardSelector).first()).toBeVisible({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("sidebar navigation changes the active panel", async () => {
|
||||||
|
const page = sharedPage;
|
||||||
|
await expect(page.locator("ha-demo")).toBeAttached({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
await waitForOrSkip(
|
||||||
|
page,
|
||||||
|
"ha-sidebar",
|
||||||
|
"visible",
|
||||||
|
SHELL_TIMEOUT,
|
||||||
|
pageErrors
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const visible = await navItem.isVisible().catch(() => false);
|
||||||
|
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: SHELL_TIMEOUT,
|
||||||
|
});
|
||||||
|
clicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(clicked, "No known sidebar panel was found to click").toBe(true);
|
||||||
|
expect(appErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("clicking an entity card opens the more-info dialog", async () => {
|
||||||
|
const page = sharedPage;
|
||||||
|
await expect(page.locator("ha-demo")).toBeAttached({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||||
|
timeout: NAVIGATION_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tile cards are the most common card type in the demo; they open the
|
||||||
|
// more-info dialog on click. Fall back to other clickable card types in
|
||||||
|
// case the demo layout on this platform doesn't include tile cards.
|
||||||
|
const cardSelector =
|
||||||
|
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
|
||||||
|
|
||||||
|
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.
|
||||||
|
// 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: SHELL_TIMEOUT });
|
||||||
|
|
||||||
|
const title = dialog.locator("span.title");
|
||||||
|
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||||
|
|
||||||
|
expect(appErrors()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: SHELL_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const realErrors = errors.filter(
|
||||||
|
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
realErrors,
|
||||||
|
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
||||||
|
).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors that are gallery-harness artifacts rather than bugs in the component
|
||||||
|
// under test. The gallery feeds demos a synchronous mock `hass`, but migrated
|
||||||
|
// components now read `localize`/formatters from Lit context, which resolves
|
||||||
|
// asynchronously — so a demo can render one frame before the context lands and
|
||||||
|
// throw a transient "undefined" error during init. These don't prevent the
|
||||||
|
// demo from rendering (the toBeAttached check above still has to pass), and
|
||||||
|
// they're timing-dependent, so we filter the whole init-error family here.
|
||||||
|
const IGNORED_ERRORS: RegExp[] = [
|
||||||
|
/ResizeObserver/,
|
||||||
|
/Non-Error/,
|
||||||
|
/Extension context/,
|
||||||
|
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||||
|
/^Object$/,
|
||||||
|
// localize consumed from context before it resolves (`this.localize` /
|
||||||
|
// `this._localize is not a function`, or `hass.localize` read as undefined).
|
||||||
|
/_?localize is not a function/,
|
||||||
|
/Cannot read properties of undefined \(reading 'localize'\)/,
|
||||||
|
// Formatters consumed from context before it resolves.
|
||||||
|
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
|
||||||
|
// hass API methods consumed from context before it resolves (e.g. the
|
||||||
|
// update demo fetches release notes via callWS during init).
|
||||||
|
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
|
||||||
|
// locale fields read before the mock locale is wired up.
|
||||||
|
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
|
||||||
|
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||||
|
// from mock state data — pre-existing gallery data issue.
|
||||||
|
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: SHELL_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 >> ha-drawer")).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: SHELL_TIMEOUT });
|
||||||
|
|
||||||
|
// 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: QUICK_TIMEOUT });
|
||||||
|
// There should be at least 4 alerts (one per type)
|
||||||
|
await expect(alerts)
|
||||||
|
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
|
||||||
|
.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: SHELL_TIMEOUT });
|
||||||
|
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
await expect(demo.locator("ha-form").first()).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
// 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: SHELL_TIMEOUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: SHELL_TIMEOUT });
|
||||||
|
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||||
|
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ha-switch toggles state on click", async ({ page }) => {
|
||||||
|
await goToGalleryPage(page, "components/ha-switch");
|
||||||
|
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||||
|
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||||
|
|
||||||
|
// Find the first interactive (non-disabled) switch. Pull its checked state
|
||||||
|
// from the property — ha-switch toggles via property, not the attribute.
|
||||||
|
const switchEl = demo.locator("ha-switch:not([disabled])").first();
|
||||||
|
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||||
|
|
||||||
|
const before = await switchEl.evaluate((el: any) => el.checked === true);
|
||||||
|
await switchEl.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
|
||||||
|
timeout: QUICK_TIMEOUT,
|
||||||
|
})
|
||||||
|
.toBe(!before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
// Values are in milliseconds.
|
||||||
|
|
||||||
|
/** Single fast UI assertion (element should already be there). */
|
||||||
|
export const QUICK_TIMEOUT = 10_000;
|
||||||
|
/** Standard app-shell readiness wait. */
|
||||||
|
export const SHELL_TIMEOUT = 15_000;
|
||||||
|
/** Heavier panel/dialog loads that involve dynamic imports. */
|
||||||
|
export const PANEL_TIMEOUT = 20_000;
|
||||||
|
/** First navigation / cold-cache loads on slow runners. */
|
||||||
|
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 =
|
||||||
|
typeof errors[0] === "string"
|
||||||
|
? (errors as string[])
|
||||||
|
: (errors as { message: string }[]).map((e) => e.message);
|
||||||
|
return messages.filter(
|
||||||
|
(msg) =>
|
||||||
|
!msg.includes("ResizeObserver") &&
|
||||||
|
!msg.includes("Non-Error") &&
|
||||||
|
!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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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" }],
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig, configDefaults } from "vitest/config";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [tsconfigPaths()],
|
||||||
test: {
|
test: {
|
||||||
|
exclude: [...configDefaults.exclude, "test/e2e/**"],
|
||||||
environment: "jsdom", // to run in browser-like environment
|
environment: "jsdom", // to run in browser-like environment
|
||||||
env: {
|
env: {
|
||||||
TZ: "Etc/UTC",
|
TZ: "Etc/UTC",
|
||||||
|
|||||||
+3
-1
@@ -157,5 +157,7 @@
|
|||||||
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
|
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
|
||||||
"lit/directives/ref": ["./node_modules/lit/directives/ref.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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user