mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-17 22:57:07 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c68973a0 | |||
| 141dce9773 | |||
| d355d9e089 | |||
| ce5a135bd2 | |||
| 8905123249 | |||
| 92e6f69fc7 | |||
| 3fbc11458a | |||
| 8b419646d1 | |||
| 5ca28bdc85 | |||
| efe41b1e1a | |||
| d16aa48f65 | |||
| 99c7a427f1 | |||
| 0ccc582aff | |||
| e32dd78f51 | |||
| c3358e0825 | |||
| 764961b28e | |||
| 897a33963e | |||
| 6eadf2ff15 | |||
| d32f5b6a50 | |||
| a21cf5d995 | |||
| 9aa6cd4154 | |||
| 2024ce0aef | |||
| 908a518f18 |
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
|
||||
# The BrowserStack SDK manages the Local tunnel and uploads results to the
|
||||
# BrowserStack Automate dashboard automatically — no tunnel action needed.
|
||||
e2e-browserstack:
|
||||
name: E2E (BrowserStack)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
environment: browserstack
|
||||
env:
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (BrowserStack)
|
||||
run: yarn test:e2e:browserstack
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local, e2e-browserstack]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
- name: Merge blob reports
|
||||
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report link to PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
@@ -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 env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
@@ -321,4 +320,22 @@ module.exports.config = {
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module */
|
||||
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||
// The original helper evaluates `Function('return require("...")')()`
|
||||
// when it detects a Node environment, which causes a runtime
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
@@ -266,3 +266,24 @@ gulp.task(
|
||||
paths.landingPage_output_es5
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
genPagesDevTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
genPagesProdTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root,
|
||||
paths.e2eTestApp_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
// Copy app static files (icons, polyfills, etc.)
|
||||
fs.copySync(
|
||||
polyPath("public/static"),
|
||||
path.resolve(paths.e2eTestApp_output_root, "static")
|
||||
);
|
||||
// Copy e2e test app public files (manifest, sw stubs)
|
||||
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
|
||||
if (fs.existsSync(e2ePublic)) {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
copyMdiIcons(paths.e2eTestApp_output_static);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
prodBuild(
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -50,4 +50,15 @@ module.exports = {
|
||||
),
|
||||
|
||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
||||
|
||||
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
|
||||
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
|
||||
e2eTestApp_output_static: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/static"
|
||||
),
|
||||
e2eTestApp_output_latest: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/frontend_latest"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global require, module, __dirname */
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
createAppConfig,
|
||||
createDemoConfig,
|
||||
@@ -345,4 +349,5 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
@@ -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,21 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
// REST mock for cloud status — returns disconnected so config panel loads
|
||||
// without errors but without requiring cloud integration.
|
||||
hass.mockAPI("cloud/status", () => ({
|
||||
logged_in: false,
|
||||
cloud: "disconnected",
|
||||
prefs: {
|
||||
google_enabled: false,
|
||||
alexa_enabled: false,
|
||||
cloudhooks: {},
|
||||
remote_enabled: false,
|
||||
},
|
||||
google_registered: false,
|
||||
alexa_registered: false,
|
||||
remote_domain: null,
|
||||
remote_connected: false,
|
||||
remote_certificate: null,
|
||||
}));
|
||||
};
|
||||
+18
-25
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
@@ -13,19 +12,6 @@ import type {
|
||||
} from "../../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const getNextDate = (
|
||||
currentDate: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): Date => {
|
||||
return period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: period === "hour"
|
||||
? addHours(currentDate, 1)
|
||||
: addMinutes(currentDate, 5);
|
||||
};
|
||||
|
||||
const generateMeanStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
@@ -39,10 +25,9 @@ const generateMeanStatistics = (
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
const mean = delta;
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean,
|
||||
min: mean - Math.random() * maxDiff,
|
||||
max: mean + Math.random() * maxDiff,
|
||||
@@ -50,7 +35,12 @@ const generateMeanStatistics = (
|
||||
state: mean,
|
||||
sum: null,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate =
|
||||
period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: addHours(currentDate, 1);
|
||||
}
|
||||
return statistics;
|
||||
};
|
||||
@@ -68,12 +58,11 @@ const generateSumStatistics = (
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const add = Math.random() * maxDiff;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean: null,
|
||||
min: null,
|
||||
max: null,
|
||||
@@ -82,7 +71,12 @@ const generateSumStatistics = (
|
||||
state: initValue + sum,
|
||||
sum,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate =
|
||||
period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: addHours(currentDate, 1);
|
||||
}
|
||||
return statistics;
|
||||
};
|
||||
@@ -90,7 +84,7 @@ const generateSumStatistics = (
|
||||
const generateCurvedStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
_period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number,
|
||||
metered: boolean
|
||||
@@ -104,12 +98,11 @@ const generateCurvedStatistics = (
|
||||
let half = false;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const add = i * (Math.random() * maxDiff);
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean: null,
|
||||
min: null,
|
||||
max: null,
|
||||
@@ -118,7 +111,7 @@ const generateCurvedStatistics = (
|
||||
state: initValue + sum,
|
||||
sum: metered ? sum : null,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate = addHours(currentDate, 1);
|
||||
if (!half && i > hours / 2) {
|
||||
half = true;
|
||||
}
|
||||
@@ -296,7 +289,7 @@ const statisticsFunctions: Record<
|
||||
end,
|
||||
period,
|
||||
productionFinalVal,
|
||||
0.2
|
||||
2
|
||||
);
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,6 +228,12 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
+40
-29
@@ -20,7 +20,16 @@
|
||||
"prepack": "pinst --disable",
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
|
||||
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
|
||||
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
@@ -28,26 +37,26 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.6",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.42.1",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.2",
|
||||
"@formatjs/intl-displaynames": "7.3.5",
|
||||
"@formatjs/intl-durationformat": "0.10.8",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.6",
|
||||
"@formatjs/intl-listformat": "8.3.5",
|
||||
"@formatjs/intl-locale": "5.3.5",
|
||||
"@formatjs/intl-numberformat": "9.3.5",
|
||||
"@formatjs/intl-pluralrules": "6.3.5",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.5",
|
||||
"@formatjs/intl-datetimeformat": "7.4.1",
|
||||
"@formatjs/intl-displaynames": "7.3.4",
|
||||
"@formatjs/intl-durationformat": "0.10.7",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.5",
|
||||
"@formatjs/intl-listformat": "8.3.4",
|
||||
"@formatjs/intl-locale": "5.3.4",
|
||||
"@formatjs/intl-numberformat": "9.3.4",
|
||||
"@formatjs/intl-pluralrules": "6.3.4",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.4",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -80,7 +89,7 @@
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.1.3",
|
||||
"barcode-detector": "3.1.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
@@ -99,7 +108,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.4",
|
||||
"intl-messageformat": "11.2.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -121,19 +130,19 @@
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-expiration": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0",
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.5",
|
||||
"@babel/preset-env": "7.29.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.60.0",
|
||||
@@ -141,8 +150,9 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.2",
|
||||
"@rspack/core": "2.0.1",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -165,6 +175,7 @@
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"browserstack-node-sdk": "1.53.2",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.3.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
@@ -175,7 +186,7 @@
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "5.0.1",
|
||||
@@ -186,7 +197,7 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "17.0.2",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -195,17 +206,17 @@
|
||||
"prettier": "3.8.3",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.15",
|
||||
"sinon": "21.1.2",
|
||||
"tar": "7.5.13",
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.2",
|
||||
"typescript-eslint": "8.59.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
|
||||
},
|
||||
"resolutions": {
|
||||
"lit": "3.3.2",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Walks up the composed tree (jumping shadow roots → their hosts), returning
|
||||
* the ancestor chain top-down. Used to compare two nodes that may live in
|
||||
* different shadow trees — `Node.compareDocumentPosition` only works within a
|
||||
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
|
||||
*/
|
||||
const composedAncestorPath = (node: Node): Node[] => {
|
||||
const path: Node[] = [];
|
||||
let cur: Node | null = node;
|
||||
while (cur) {
|
||||
path.push(cur);
|
||||
const parent = cur.parentNode;
|
||||
if (parent instanceof ShadowRoot) {
|
||||
cur = parent.host;
|
||||
} else if (parent) {
|
||||
cur = parent;
|
||||
} else {
|
||||
const root = cur.getRootNode();
|
||||
cur = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
}
|
||||
return path.reverse();
|
||||
};
|
||||
|
||||
/**
|
||||
* Document-order comparator that works across shadow boundaries. Suitable as
|
||||
* the `Array.prototype.sort` callback for collections of nodes that may live
|
||||
* in different shadow trees.
|
||||
*/
|
||||
export const compareNodeOrder = (a: Node, b: Node): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const pa = composedAncestorPath(a);
|
||||
const pb = composedAncestorPath(b);
|
||||
let i = 0;
|
||||
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
|
||||
i++;
|
||||
}
|
||||
if (i === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (i === pa.length) {
|
||||
return -1;
|
||||
}
|
||||
if (i === pb.length) {
|
||||
return 1;
|
||||
}
|
||||
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1;
|
||||
};
|
||||
@@ -117,6 +117,9 @@ export const generateEntityFilter = (
|
||||
}
|
||||
}
|
||||
if (entityCategories) {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const category = entity?.entity_category || "none";
|
||||
if (!entityCategories.has(category)) {
|
||||
return false;
|
||||
|
||||
@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
|
||||
private _updateSankeyRoam() {
|
||||
const option = this.chart?.getOption();
|
||||
const sankeySeries = (option?.series as any[])?.filter(
|
||||
(s: any) => s != null && s.type === "sankey"
|
||||
(s: any) => s.type === "sankey"
|
||||
);
|
||||
if (sankeySeries?.length) {
|
||||
this.chart?.setOption({
|
||||
|
||||
@@ -59,7 +59,6 @@ export class HaExpansionPanel extends LitElement {
|
||||
<slot class="secondary" name="secondary">${this.secondary}</slot>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="event"></slot>
|
||||
${!this.leftChevron ? chevronIcon : nothing}
|
||||
<slot name="icons"></slot>
|
||||
</div>
|
||||
|
||||
@@ -228,10 +228,6 @@ export class HaSwitch extends Switch {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: var(--wa-focus-ring-offset);
|
||||
}
|
||||
|
||||
:host(:empty) slot.label {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class HaInputMulti extends LitElement {
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this._items,
|
||||
(_item, index) => index,
|
||||
(item, index) => `${item}-${index}`,
|
||||
(item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
@@ -126,7 +126,7 @@ class HaInputMulti extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal add-row">
|
||||
<div class="layout horizontal">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="filled"
|
||||
@@ -218,9 +218,6 @@ class HaInputMulti extends LitElement {
|
||||
margin-bottom: 8px;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.add-row:has(+ ha-input-helper-text) {
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-icon-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../list/types";
|
||||
import { HaRowItem } from "./ha-row-item";
|
||||
|
||||
/**
|
||||
@@ -41,12 +39,6 @@ export class HaListItemBase extends HaRowItem {
|
||||
if (!this.hasAttribute("role")) {
|
||||
this.setAttribute("role", this.defaultRole);
|
||||
}
|
||||
fireEvent(this, "ha-list-item-register", { item: this });
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
fireEvent(this, "ha-list-item-unregister", { item: this });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,20 +62,26 @@ export class HaRowItem extends LitElement {
|
||||
}
|
||||
|
||||
protected _renderInner(): TemplateResult {
|
||||
const hasStart = this._slotController.test("start");
|
||||
const hasEnd = this._slotController.test("end");
|
||||
const hasContent = this._slotController.test("content");
|
||||
|
||||
return html`
|
||||
<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
${hasStart
|
||||
? html`<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div part="content" class="content">
|
||||
${hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()}
|
||||
</div>
|
||||
<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
${hasEnd
|
||||
? html`<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,10 +148,6 @@ export class HaRowItem extends LitElement {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host(:not(:has([slot="start"]))) .start,
|
||||
:host(:not(:has([slot="end"]))) .end {
|
||||
display: none;
|
||||
}
|
||||
.headline {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { compareNodeOrder } from "../../common/dom/compare-node-order";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import "./types";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
|
||||
/**
|
||||
* @element ha-list-base
|
||||
@@ -14,11 +12,9 @@ import type { HaListItemRegistrationDetail } from "./types";
|
||||
*
|
||||
* @summary
|
||||
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
|
||||
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
|
||||
* `HaListItemBase` descendants via the `ha-list-item-register` /
|
||||
* `ha-list-item-unregister` events they fire on connect/disconnect — works
|
||||
* across any nesting depth and shadow boundaries. Subclasses override
|
||||
* `hostRole` and/or `render()` to specialize.
|
||||
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
|
||||
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
|
||||
* `render()` to specialize.
|
||||
*
|
||||
* @slot - List items (`<ha-list-item-*>`).
|
||||
*
|
||||
@@ -72,14 +68,6 @@ export class HaListBase extends LitElement {
|
||||
Space: this._onActivate,
|
||||
});
|
||||
this.addEventListener("focusin", this._onFocusIn);
|
||||
this.addEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.addEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -87,14 +75,11 @@ export class HaListBase extends LitElement {
|
||||
this._unbindKeys?.();
|
||||
this._unbindKeys = undefined;
|
||||
this.removeEventListener("focusin", this._onFocusIn);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public firstUpdated(changed: PropertyValues) {
|
||||
super.firstUpdated(changed);
|
||||
this.updateListItems();
|
||||
}
|
||||
|
||||
public focus(options?: FocusOptions) {
|
||||
@@ -130,14 +115,18 @@ export class HaListBase extends LitElement {
|
||||
this._applyActive(focusItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook called whenever the items array has changed. Subclasses can override
|
||||
* to layer in extra bookkeeping (e.g. selection state sync).
|
||||
*/
|
||||
public updateListItems() {
|
||||
const next = this._discoverListItems();
|
||||
const changed =
|
||||
next.length !== this.items.length ||
|
||||
next.some((it, i) => it !== this.items[i]);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
this.items = next;
|
||||
this._recomputeFocusableIndexes();
|
||||
if (
|
||||
this._activeItemIndex >= this.items.length ||
|
||||
this._activeItemIndex >= next.length ||
|
||||
!this._hasFocusableItem ||
|
||||
this._activeItemIndex < 0
|
||||
) {
|
||||
@@ -146,32 +135,6 @@ export class HaListBase extends LitElement {
|
||||
this._applyActive(false);
|
||||
}
|
||||
|
||||
private _onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const item = ev.detail.item;
|
||||
if (this.items.includes(item)) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.items, item];
|
||||
next.sort(compareNodeOrder);
|
||||
this.items = next;
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const item = ev.detail.item;
|
||||
if (!this.items.includes(item)) {
|
||||
return;
|
||||
}
|
||||
this.items = this.items.filter((it) => it !== item);
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _recomputeFocusableIndexes() {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
@@ -188,12 +151,27 @@ export class HaListBase extends LitElement {
|
||||
this._hasFocusableItem = first !== -1;
|
||||
}
|
||||
|
||||
public handleSlotChange = () => {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<div part="base" class="base">
|
||||
<slot></slot>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _discoverListItems(): HaListItemBase[] {
|
||||
const slot =
|
||||
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
|
||||
if (!slot) {
|
||||
return [];
|
||||
}
|
||||
return slot
|
||||
.assignedElements({ flatten: true })
|
||||
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
|
||||
}
|
||||
|
||||
private _isFocusable(index: number): boolean {
|
||||
const item = this.items[index];
|
||||
return !!item && item.interactive && !item.disabled;
|
||||
|
||||
@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
|
||||
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
|
||||
>
|
||||
<div part="base" class="base" role="list">
|
||||
<slot></slot>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,9 @@ export interface HaListActivatedDetail {
|
||||
item: HaListItemBase;
|
||||
}
|
||||
|
||||
export interface HaListItemRegistrationDetail {
|
||||
item: HaListItemBase;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-list-selected": HaListSelectedDetail;
|
||||
"ha-list-activated": HaListActivatedDetail;
|
||||
"ha-list-item-register": HaListItemRegistrationDetail;
|
||||
"ha-list-item-unregister": HaListItemRegistrationDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { localizeTriggerDescription } from "../../data/logbook";
|
||||
import type {
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
@@ -333,10 +332,7 @@ class ActionRenderer {
|
||||
: "other",
|
||||
alias: triggerStep.changed_variables.trigger?.alias,
|
||||
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
|
||||
trigger: localizeTriggerDescription(
|
||||
this.hass.localize,
|
||||
this.trace.trigger
|
||||
),
|
||||
trigger: this.trace.trigger,
|
||||
time: formatDateTimeWithSeconds(
|
||||
new Date(triggerStep.timestamp),
|
||||
this.hass.locale,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface HttpConfig {
|
||||
server_host?: string[];
|
||||
server_port?: number;
|
||||
ssl_certificate?: string;
|
||||
ssl_peer_certificate?: string;
|
||||
ssl_key?: string;
|
||||
cors_allowed_origins?: string[];
|
||||
use_x_forwarded_for?: boolean;
|
||||
trusted_proxies?: string[];
|
||||
use_x_frame_options?: boolean;
|
||||
ip_ban_enabled?: boolean;
|
||||
login_attempts_threshold?: number;
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
interface HttpConfigResponse {
|
||||
config: HttpConfig;
|
||||
}
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigResponse>({ type: "http/config/get" });
|
||||
|
||||
export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) =>
|
||||
hass.callWS<HttpConfigResponse>({
|
||||
type: "http/config/update",
|
||||
config,
|
||||
});
|
||||
@@ -195,49 +195,6 @@ export const localizeTriggerSource = (
|
||||
return source;
|
||||
};
|
||||
|
||||
// Mapping from a phrase key to the bare-phrase translation key (without the
|
||||
// "triggered by" prefix), used by localizeTriggerDescription below.
|
||||
const triggerDescriptionKeys: Record<
|
||||
TriggerPhraseKeys,
|
||||
| "numeric_state_of"
|
||||
| "state_of"
|
||||
| "event"
|
||||
| "time"
|
||||
| "time_pattern"
|
||||
| "homeassistant_stopping"
|
||||
| "homeassistant_starting"
|
||||
> = {
|
||||
triggered_by_numeric_state_of: "numeric_state_of",
|
||||
triggered_by_state_of: "state_of",
|
||||
triggered_by_event: "event",
|
||||
triggered_by_time_pattern: "time_pattern",
|
||||
triggered_by_time: "time",
|
||||
triggered_by_homeassistant_stopping: "homeassistant_stopping",
|
||||
triggered_by_homeassistant_starting: "homeassistant_starting",
|
||||
};
|
||||
|
||||
// Like localizeTriggerSource, but returns just the bare localized trigger
|
||||
// description (without the "triggered by" prefix). Used where the surrounding
|
||||
// template already supplies its own "triggered by" wording.
|
||||
export const localizeTriggerDescription = (
|
||||
localize: LocalizeFunc,
|
||||
source: string
|
||||
) => {
|
||||
for (const triggerPhraseKey of Object.keys(
|
||||
triggerPhrases
|
||||
) as TriggerPhraseKeys[]) {
|
||||
const phrase = triggerPhrases[triggerPhraseKey];
|
||||
if (source.startsWith(phrase)) {
|
||||
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
|
||||
return source.replace(
|
||||
phrase,
|
||||
`${localize(`ui.components.logbook.${bareKey}`)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
};
|
||||
|
||||
export const localizeStateMessage = (
|
||||
hass: HomeAssistant,
|
||||
localize: LocalizeFunc,
|
||||
|
||||
@@ -3,13 +3,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
|
||||
import {
|
||||
@@ -43,33 +40,14 @@ interface FlowUpdateEvent {
|
||||
stepPromise?: Promise<DataEntryFlowStep>;
|
||||
}
|
||||
|
||||
interface FlowStepFooterStateChangedEvent {
|
||||
loading?: boolean;
|
||||
hasPendingUpdates?: boolean;
|
||||
}
|
||||
|
||||
interface FormStepElement extends HTMLElement {
|
||||
submit(): Promise<void>;
|
||||
}
|
||||
|
||||
interface AbortStepElement extends HTMLElement {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface CreateEntryStepElement extends HTMLElement {
|
||||
finish(): Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
interface HASSDomEvents {
|
||||
"flow-update": FlowUpdateEvent;
|
||||
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
|
||||
}
|
||||
// for add event listener
|
||||
interface HTMLElementEventMap {
|
||||
"flow-update": HASSDomEvent<FlowUpdateEvent>;
|
||||
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +73,6 @@ class DataEntryFlowDialog extends LitElement {
|
||||
|
||||
@state() private _handler?: string;
|
||||
|
||||
@state() private _formStepLoading = false;
|
||||
|
||||
@state() private _createEntryHasPendingUpdates = false;
|
||||
|
||||
private _formStepRef = createRef<FormStepElement>();
|
||||
|
||||
private _abortStepRef = createRef<AbortStepElement>();
|
||||
|
||||
private _createEntryStepRef = createRef<CreateEntryStepElement>();
|
||||
|
||||
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
|
||||
|
||||
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
|
||||
@@ -398,14 +366,11 @@ class DataEntryFlowDialog extends LitElement {
|
||||
${this._step.type === "form"
|
||||
? html`
|
||||
<step-flow-form
|
||||
${ref(this._formStepRef)}
|
||||
autofocus
|
||||
narrow
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
@flow-step-footer-state-changed=${this
|
||||
._handleFooterStateChanged}
|
||||
></step-flow-form>
|
||||
`
|
||||
: this._step.type === "external"
|
||||
@@ -419,7 +384,6 @@ class DataEntryFlowDialog extends LitElement {
|
||||
: this._step.type === "abort"
|
||||
? html`
|
||||
<step-flow-abort
|
||||
${ref(this._abortStepRef)}
|
||||
.params=${this._params}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
@@ -447,14 +411,11 @@ class DataEntryFlowDialog extends LitElement {
|
||||
`
|
||||
: html`
|
||||
<step-flow-create-entry
|
||||
${ref(this._createEntryStepRef)}
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
.navigateToResult=${this._params
|
||||
.navigateToResult ?? false}
|
||||
@flow-step-footer-state-changed=${this
|
||||
._handleFooterStateChanged}
|
||||
.devices=${this._devices(
|
||||
this._params.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
@@ -465,95 +426,10 @@ class DataEntryFlowDialog extends LitElement {
|
||||
`}
|
||||
`}
|
||||
</div>
|
||||
${this._renderFooter()}
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFooter() {
|
||||
if (!this._step || this._loading) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
switch (this._step.type) {
|
||||
case "form":
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.loading=${this._formStepLoading}
|
||||
@click=${this._submitFormStep}
|
||||
>
|
||||
${this._params!.flowConfig.renderShowFormStepSubmitButton(
|
||||
this.hass,
|
||||
this._step
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "abort":
|
||||
return this._step.reason === "missing_credentials"
|
||||
? nothing
|
||||
: html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._closeAbortStep}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.close"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "external":
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
href=${this._step.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
case "create_entry": {
|
||||
const devices = this._devices(
|
||||
this._params!.flowConfig.showDevices,
|
||||
Object.values(this.hass.devices),
|
||||
this._step.result?.entry_id,
|
||||
this._params!.carryOverDevices
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._finishCreateEntryStep}
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.integrations.config_flow.${
|
||||
!devices.length ||
|
||||
this._createEntryHasPendingUpdates ||
|
||||
devices.some((device) => device.area_id)
|
||||
? "finish"
|
||||
: "finish_skip"
|
||||
}`
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`;
|
||||
}
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("flow-update", (ev) => {
|
||||
@@ -603,8 +479,6 @@ class DataEntryFlowDialog extends LitElement {
|
||||
}
|
||||
|
||||
this._step = undefined;
|
||||
this._formStepLoading = false;
|
||||
this._createEntryHasPendingUpdates = false;
|
||||
await this.updateComplete;
|
||||
this._step = _step;
|
||||
if (
|
||||
@@ -688,36 +562,20 @@ class DataEntryFlowDialog extends LitElement {
|
||||
}
|
||||
|
||||
await this.updateComplete;
|
||||
this._formStepRef.value?.focus();
|
||||
};
|
||||
|
||||
private _handleFooterStateChanged = (
|
||||
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
|
||||
) => {
|
||||
if (ev.detail.loading !== undefined) {
|
||||
this._formStepLoading = ev.detail.loading;
|
||||
}
|
||||
if (ev.detail.hasPendingUpdates !== undefined) {
|
||||
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
|
||||
}
|
||||
};
|
||||
|
||||
private _submitFormStep = () => {
|
||||
this._formStepRef.value?.submit();
|
||||
};
|
||||
|
||||
private _closeAbortStep = () => {
|
||||
this._abortStepRef.value?.close();
|
||||
};
|
||||
|
||||
private _finishCreateEntryStep = () => {
|
||||
this._createEntryStepRef.value?.finish();
|
||||
(
|
||||
this.renderRoot.querySelector(
|
||||
"step-flow-form[autofocus]"
|
||||
) as HTMLElement | null
|
||||
)?.focus();
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
.dialog-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { showConfigFlowDialog } from "./show-dialog-config-flow";
|
||||
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
import "../../components/ha-button";
|
||||
|
||||
@customElement("step-flow-abort")
|
||||
class StepFlowAbort extends LitElement {
|
||||
@@ -36,6 +37,13 @@ class StepFlowAbort extends LitElement {
|
||||
<div class="content">
|
||||
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<ha-button appearance="plain" @click=${this._flowDone}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.close"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -60,10 +68,6 @@ class StepFlowAbort extends LitElement {
|
||||
fireEvent(this, "flow-update", { step: undefined });
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this._flowDone();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return configFlowContentStyles;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import "../../components/ha-area-picker";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/input/ha-input";
|
||||
import type { HaInput } from "../../components/input/ha-input";
|
||||
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
|
||||
@@ -30,10 +31,6 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
|
||||
interface DeviceTarget {
|
||||
device: string;
|
||||
}
|
||||
|
||||
@customElement("step-flow-create-entry")
|
||||
class StepFlowCreateEntry extends LitElement {
|
||||
@property({ attribute: false }) public flowConfig!: FlowConfig;
|
||||
@@ -195,18 +192,20 @@ class StepFlowCreateEntry extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<ha-button @click=${this._flowDone}
|
||||
>${localize(
|
||||
`ui.panel.config.integrations.config_flow.${
|
||||
!this.devices.length || Object.keys(this._deviceUpdate).length
|
||||
? "finish"
|
||||
: "finish_skip"
|
||||
}`
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_deviceUpdate")) {
|
||||
fireEvent(this, "flow-step-footer-state-changed", {
|
||||
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadDomains() {
|
||||
const entries = await getConfigEntries(this.hass);
|
||||
this._domains = Object.fromEntries(
|
||||
@@ -225,20 +224,18 @@ class StepFlowCreateEntry extends LitElement {
|
||||
return updateDeviceRegistryEntry(this.hass, deviceId, {
|
||||
name_by_user: update.name,
|
||||
area_id: update.area,
|
||||
}).catch((err: unknown) => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown error";
|
||||
}).catch((err: any) => {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error_saving_device",
|
||||
{ error: message }
|
||||
{ error: err.message }
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
await Promise.allSettled(deviceUpdates);
|
||||
const entityUpdates: Promise<unknown>[] = [];
|
||||
const entityUpdates: Promise<any>[] = [];
|
||||
const entityIds: string[] = [];
|
||||
renamedDevices.forEach((deviceId) => {
|
||||
const entities = this._deviceEntities(
|
||||
@@ -284,15 +281,8 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public finish(): Promise<void> {
|
||||
return this._flowDone();
|
||||
}
|
||||
|
||||
private async _areaPicked(ev: ValueChangedEvent<string>) {
|
||||
const picker = ev.currentTarget as DeviceTarget | null;
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
const picker = ev.currentTarget as any;
|
||||
const device = picker.device;
|
||||
const area = ev.detail.value;
|
||||
|
||||
@@ -304,11 +294,8 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
|
||||
private _deviceNameChanged(ev: InputEvent): void {
|
||||
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
const device = picker.device;
|
||||
const picker = ev.currentTarget as HaInput;
|
||||
const device = (picker as any).device;
|
||||
const name = picker.value;
|
||||
|
||||
if (!(device in this._deviceUpdate)) {
|
||||
@@ -324,13 +311,22 @@ class StepFlowCreateEntry extends LitElement {
|
||||
css`
|
||||
.devices {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
margin: -4px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.devices {
|
||||
/* header - margin content - footer */
|
||||
max-height: calc(100vh - 52px - 20px - 52px);
|
||||
}
|
||||
}
|
||||
.device {
|
||||
border: 1px solid var(--divider-color);
|
||||
padding: 6px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
margin: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.device-info {
|
||||
@@ -356,6 +352,11 @@ class StepFlowCreateEntry extends LitElement {
|
||||
ha-input {
|
||||
margin: var(--ha-space-2) 0;
|
||||
}
|
||||
.buttons > *:last-child {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
import "../../components/ha-button";
|
||||
|
||||
@customElement("step-flow-external")
|
||||
class StepFlowExternal extends LitElement {
|
||||
@@ -15,9 +16,18 @@ class StepFlowExternal extends LitElement {
|
||||
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const localize = this.hass.localize;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
|
||||
<div class="open-button">
|
||||
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
|
||||
${localize(
|
||||
"ui.panel.config.integrations.config_flow.external_step.open_site"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -28,7 +38,18 @@ class StepFlowExternal extends LitElement {
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [configFlowContentStyles];
|
||||
return [
|
||||
configFlowContentStyles,
|
||||
css`
|
||||
.open-button {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.open-button a {
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-alert";
|
||||
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
|
||||
import "../../components/ha-form/ha-form";
|
||||
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
|
||||
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
|
||||
import { previewModule } from "../../data/preview";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
|
||||
@@ -47,8 +47,6 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
private _errors?: Record<string, string>;
|
||||
|
||||
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
|
||||
|
||||
static shadowRootOptions: ShadowRootInit = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
@@ -90,27 +88,24 @@ class StepFlowForm extends LitElement {
|
||||
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
|
||||
${this._errorMsg
|
||||
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
|
||||
: nothing}
|
||||
${step.data_schema.length
|
||||
? html`<ha-form
|
||||
${ref(this._formRef)}
|
||||
?autofocus=${this.autoFocus}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.data=${stepData}
|
||||
.disabled=${this._loading}
|
||||
@value-changed=${this._stepDataChanged}
|
||||
.schema=${autocompleteLoginFields(
|
||||
this.handleReadOnlyFields(step.data_schema)
|
||||
)}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${this._labelCallback}
|
||||
.computeHelper=${this._helperCallback}
|
||||
.computeError=${this._errorCallback}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.context=${{ handler: step.handler }}
|
||||
></ha-form>`
|
||||
: nothing}
|
||||
: ""}
|
||||
<ha-form
|
||||
?autofocus=${this.autoFocus}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.data=${stepData}
|
||||
.disabled=${this._loading}
|
||||
@value-changed=${this._stepDataChanged}
|
||||
.schema=${autocompleteLoginFields(
|
||||
this.handleReadOnlyFields(step.data_schema)
|
||||
)}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${this._labelCallback}
|
||||
.computeHelper=${this._helperCallback}
|
||||
.computeError=${this._errorCallback}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.context=${{ handler: step.handler }}
|
||||
></ha-form>
|
||||
</div>
|
||||
${step.preview
|
||||
? html`<div class="preview" @set-flow-errors=${this._setError}>
|
||||
@@ -130,6 +125,14 @@ class StepFlowForm extends LitElement {
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="buttons">
|
||||
<ha-button @click=${this._submitStep} .loading=${this._loading}>
|
||||
${this.flowConfig.renderShowFormStepSubmitButton(
|
||||
this.hass,
|
||||
this.step
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,17 +145,8 @@ class StepFlowForm extends LitElement {
|
||||
this.addEventListener("keydown", this._handleKeyDown);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_loading")) {
|
||||
fireEvent(this, "flow-step-footer-state-changed", {
|
||||
loading: this._loading,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override focus(_options?: FocusOptions): void {
|
||||
this._formRef.value?.focus();
|
||||
this.renderRoot.querySelector("ha-form")?.focus();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
@@ -235,15 +229,13 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
const flowId = this.step.flow_id;
|
||||
|
||||
const toSendData: Record<string, unknown> = {};
|
||||
const toSendData = {};
|
||||
Object.keys(stepData).forEach((key) => {
|
||||
const value = stepData[key];
|
||||
const isEmpty = [undefined, ""].includes(value);
|
||||
const field = this.step.data_schema?.find((f) => f.name === key);
|
||||
const selector = (field as HaFormSelector)?.selector ?? {};
|
||||
const read_only = (
|
||||
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
|
||||
)?.read_only;
|
||||
const read_only = (Object.values(selector)[0] as any)?.read_only;
|
||||
if (!isEmpty && !read_only) {
|
||||
toSendData[key] = value;
|
||||
}
|
||||
@@ -285,13 +277,7 @@ class StepFlowForm extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public submit(): Promise<void> {
|
||||
return this._submitStep();
|
||||
}
|
||||
|
||||
private _stepDataChanged(
|
||||
ev: ValueChangedEvent<Record<string, unknown>>
|
||||
): void {
|
||||
private _stepDataChanged(ev: CustomEvent): void {
|
||||
this._stepData = ev.detail.value;
|
||||
}
|
||||
|
||||
@@ -335,9 +321,13 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
ha-alert,
|
||||
ha-form {
|
||||
margin-top: var(--ha-space-6);
|
||||
margin-top: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding: 16px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../components/ha-spinner";
|
||||
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
|
||||
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
${description ? html`<div>${description}</div>` : nothing}
|
||||
${description ? html`<div>${description}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
|
||||
text-align: center;
|
||||
}
|
||||
ha-spinner {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
${description ? html`<div class="content">${description}</div>` : nothing}
|
||||
${description ? html`<div class="content">${description}</div>` : ""}
|
||||
<div class="options">
|
||||
${options.map(
|
||||
(option) => html`
|
||||
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
|
||||
configFlowContentStyles,
|
||||
css`
|
||||
.options {
|
||||
margin-top: var(--ha-space-5);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.content {
|
||||
padding-bottom: var(--ha-space-4);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.content + .options {
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-top: 8px;
|
||||
}
|
||||
ha-list-item {
|
||||
--mdc-list-side-padding: var(--ha-space-6);
|
||||
--mdc-list-side-padding: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
|
||||
text-align: center;
|
||||
}
|
||||
ha-spinner {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -2,9 +2,12 @@ import { css } from "lit";
|
||||
|
||||
export const configFlowContentStyles = css`
|
||||
h2 {
|
||||
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
|
||||
margin: 24px 38px 0 0;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: var(--ha-space-10);
|
||||
margin-inline-end: 38px;
|
||||
padding: 0 24px;
|
||||
padding-inline-start: 24px;
|
||||
padding-inline-end: 24px;
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
font-family: var(
|
||||
@@ -25,8 +28,19 @@ export const configFlowContentStyles = css`
|
||||
|
||||
.content,
|
||||
.preview {
|
||||
margin-top: var(--ha-space-5);
|
||||
margin-top: 20px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
ha-markdown {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -412,6 +412,10 @@ export const provideHass = (
|
||||
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
},
|
||||
],
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
...overrideData,
|
||||
};
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
private _renderRow(row = true) {
|
||||
const type = getAutomationActionType(this.action);
|
||||
|
||||
const action = type === "service" && (this.action as ServiceAction).action;
|
||||
@@ -331,7 +331,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="svg-icon">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.continue_on_error_description"
|
||||
"ui.panel.config.automation.editor.actions.continue_on_error"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
@@ -339,10 +339,10 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._running}
|
||||
.variant=${this._runResult?.variant}
|
||||
slot="event"
|
||||
.slot=${row ? "event" : ""}
|
||||
aria-live="polite"
|
||||
.interactive=${!!this._runResult?.details}
|
||||
class="event-chip"
|
||||
class=${row ? "" : "event-chip"}
|
||||
@click=${this._showRunResultDetails}
|
||||
@keydown=${this._showRunResultDetails}
|
||||
>
|
||||
@@ -640,7 +640,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
${this._renderRow(false)}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
</ha-card>
|
||||
@@ -1148,7 +1148,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
overflowStyles,
|
||||
css`
|
||||
ha-svg-icon.arrow-right {
|
||||
--icon-primary-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
--icon-primary-color: var(--ha-color-fill-neutral-normal-resting);
|
||||
}
|
||||
ha-svg-icon#svg-icon {
|
||||
--icon-primary-color: var(--ha-color-fill-neutral-loud-active);
|
||||
|
||||
@@ -169,7 +169,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
private _renderRow(row = true) {
|
||||
const descriptionHasTarget =
|
||||
"target" in (this.conditionDescriptions[this.condition.condition] || {});
|
||||
|
||||
@@ -197,8 +197,8 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._testing}
|
||||
.variant=${this._testingResult ? "success" : "warning"}
|
||||
slot="event"
|
||||
class="event-chip"
|
||||
.slot=${row ? "event" : ""}
|
||||
class=${row ? "" : "event-chip"}
|
||||
aria-live="polite"
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -480,7 +480,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
${this._renderRow(false)}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
</ha-card>
|
||||
|
||||
@@ -196,7 +196,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
private _renderRow(row = true) {
|
||||
const type = this._getType(this.trigger, this.triggerDescriptions);
|
||||
|
||||
const supported = this._uiSupported(type);
|
||||
@@ -234,8 +234,8 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
</h3>
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._triggered}
|
||||
slot="event"
|
||||
class="event-chip"
|
||||
.slot=${row ? "event" : ""}
|
||||
class=${row ? "" : "event-chip"}
|
||||
interactive
|
||||
aria-live="polite"
|
||||
@click=${this._showTriggeredInfo}
|
||||
@@ -499,7 +499,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
${this._renderRow(false)}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
</ha-card>
|
||||
|
||||
@@ -80,9 +80,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
|
||||
agent_ids: string[];
|
||||
}
|
||||
|
||||
const TYPE_FILTER = "ha-filter-states";
|
||||
const LOCATIONS_FILTER = "backup-locations";
|
||||
|
||||
@customElement("ha-config-backup-backups")
|
||||
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -339,8 +336,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
localize: LocalizeFunc,
|
||||
isHassio: boolean
|
||||
): BackupRow[] => {
|
||||
const typeFilter = filters[TYPE_FILTER] as string[] | undefined;
|
||||
const locationFilter = filters[LOCATIONS_FILTER] as string[] | undefined;
|
||||
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
|
||||
let filteredBackups = backups;
|
||||
if (typeFilter?.length) {
|
||||
filteredBackups = filteredBackups.filter((backup) => {
|
||||
@@ -348,13 +344,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
return typeFilter.includes(type);
|
||||
});
|
||||
}
|
||||
if (locationFilter?.length) {
|
||||
filteredBackups = filteredBackups.filter((backup) =>
|
||||
Object.keys(backup.agents).some((agentId) =>
|
||||
locationFilter.includes(agentId)
|
||||
)
|
||||
);
|
||||
}
|
||||
return filteredBackups.map((backup) => {
|
||||
const type = computeBackupType(backup, isHassio);
|
||||
const agentIds = Object.keys(backup.agents);
|
||||
@@ -369,27 +358,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
|
||||
Math.max(1, ...data.map((row) => row.agent_ids.length))
|
||||
);
|
||||
|
||||
private _locations = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
agents: BackupAgent[],
|
||||
backups: BackupContent[]
|
||||
) => {
|
||||
const agentIds = new Set(agents.map((agent) => agent.agent_id));
|
||||
backups.forEach((backup) => {
|
||||
Object.keys(backup.agents).forEach((agentId) => agentIds.add(agentId));
|
||||
});
|
||||
|
||||
return Array.from(agentIds)
|
||||
.sort(compareAgents)
|
||||
.map((agentId) => ({
|
||||
value: agentId,
|
||||
label: computeBackupAgentName(localize, agentId, agents),
|
||||
}));
|
||||
}
|
||||
Math.max(...data.map((row) => row.agent_ids.length))
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -442,7 +411,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
@clear-filter=${this._clearFilter}
|
||||
.route=${this.route}
|
||||
@row-click=${this._showBackupDetails}
|
||||
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
|
||||
@@ -499,23 +467,11 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
<ha-filter-states
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
|
||||
.value=${this._filters[TYPE_FILTER]}
|
||||
.value="${this._filters["ha-filter-states"]}q"
|
||||
.states=${this._states(this.hass.localize, isHassio)}
|
||||
@data-table-filter-changed=${this._typeFilterChanged}
|
||||
slot="filter-pane"
|
||||
.narrow=${this.narrow}
|
||||
></ha-filter-states>
|
||||
<ha-filter-states
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize("ui.panel.config.backup.locations")}
|
||||
.value=${this._filters[LOCATIONS_FILTER]}
|
||||
.states=${this._locations(
|
||||
this.hass.localize,
|
||||
this.agents,
|
||||
this.backups
|
||||
)}
|
||||
@data-table-filter-changed=${this._locationsFilterChanged}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
expanded
|
||||
.narrow=${this.narrow}
|
||||
></ha-filter-states>
|
||||
${!this._needsOnboarding
|
||||
@@ -566,16 +522,9 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
}))
|
||||
);
|
||||
|
||||
private _typeFilterChanged(ev) {
|
||||
this._filters = { ...this._filters, [TYPE_FILTER]: ev.detail.value };
|
||||
}
|
||||
|
||||
private _locationsFilterChanged(ev) {
|
||||
this._filters = { ...this._filters, [LOCATIONS_FILTER]: ev.detail.value };
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
private _filterChanged(ev) {
|
||||
const type = ev.target.localName;
|
||||
this._filters = { ...this._filters, [type]: ev.detail.value };
|
||||
}
|
||||
|
||||
private _setFiltersFromUrl() {
|
||||
@@ -587,7 +536,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._filters = {
|
||||
[TYPE_FILTER]: type === "all" ? [] : [type],
|
||||
"ha-filter-states": type === "all" ? [] : [type],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import type {
|
||||
BackupAgent,
|
||||
BackupConfig,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
subscribeBackupEvents,
|
||||
} from "../../../data/backup_manager";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { subscribeConfigEntries } from "../../../data/config_entries";
|
||||
import type { RouterOptions } from "../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../layouts/hass-router-page";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
@@ -33,7 +31,6 @@ declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-refresh-backup-info": undefined;
|
||||
"ha-refresh-backup-config": undefined;
|
||||
"ha-refresh-backup-agents": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +57,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
{ uploaded_bytes: number; total_bytes: number }
|
||||
> = {};
|
||||
|
||||
private _debouncedFetchBackupAgents = debounce(
|
||||
() => this._fetchBackupAgents(),
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._fetchAll();
|
||||
@@ -100,11 +91,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
this._debouncedFetchBackupAgents.cancel();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async _fetchBackupInfo() {
|
||||
this._info = await fetchBackupInfo(this.hass);
|
||||
}
|
||||
@@ -178,15 +164,6 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
|
||||
public hassSubscribe(): Promise<UnsubscribeFunc>[] {
|
||||
return [
|
||||
subscribeConfigEntries(
|
||||
this.hass,
|
||||
(messages) => {
|
||||
if (messages.some((message) => message.type !== null)) {
|
||||
this._debouncedFetchBackupAgents();
|
||||
}
|
||||
},
|
||||
{ type: ["service"] }
|
||||
),
|
||||
subscribeBackupEvents(this.hass!, (event) => {
|
||||
if ("agent_id" in event) {
|
||||
this._uploadProgress = {
|
||||
|
||||
@@ -224,7 +224,7 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._externalAccess = this.hass.config.external_url != null;
|
||||
this._externalAccess = this.hass.config.external_url !== null;
|
||||
}
|
||||
|
||||
private async _fetchLabFeatures() {
|
||||
|
||||
@@ -291,20 +291,40 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this.configEntriesInProgress
|
||||
);
|
||||
|
||||
const discoveryFlows = this._discoveryFlows(
|
||||
configEntriesInProgress,
|
||||
this.hass.locale.language
|
||||
const discoveryFlows = configEntriesInProgress
|
||||
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
|
||||
.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
a.localized_title || "zzz",
|
||||
b.localized_title || "zzz",
|
||||
this.hass.locale.language
|
||||
)
|
||||
);
|
||||
|
||||
const attentionFlows = configEntriesInProgress.filter((flow) =>
|
||||
ATTENTION_SOURCES.includes(flow.context.source)
|
||||
);
|
||||
|
||||
const attentionFlows = this._attentionFlows(configEntriesInProgress);
|
||||
|
||||
const attentionEntries = this._attentionEntries(configEntries);
|
||||
|
||||
const normalEntries = this._normalEntries(
|
||||
configEntries,
|
||||
this.hass.locale.language
|
||||
const attentionEntries = configEntries.filter((entry) =>
|
||||
ERROR_STATES.includes(entry.state)
|
||||
);
|
||||
|
||||
const normalEntries = configEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
|
||||
return a.disabled_by ? 1 : -1;
|
||||
}
|
||||
return caseInsensitiveStringCompare(
|
||||
a.title,
|
||||
b.title,
|
||||
this.hass.locale.language
|
||||
);
|
||||
});
|
||||
|
||||
const normalData = this._buildNormalEntryData(
|
||||
normalEntries,
|
||||
this.hass.devices,
|
||||
@@ -324,14 +344,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this._filter,
|
||||
this.hass.areas
|
||||
);
|
||||
const filteredDiscoveryData = this._filterDiscoveryTree(
|
||||
discoveryFlows,
|
||||
this._filter
|
||||
);
|
||||
const filteredAttentionFlows = this._filterAttentionFlowTree(
|
||||
attentionFlows,
|
||||
this._filter
|
||||
);
|
||||
const filteredAttentionData = this._filterAttentionTree(
|
||||
attentionData,
|
||||
this._filter,
|
||||
@@ -680,7 +692,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</ha-alert>
|
||||
</div>`
|
||||
: nothing}
|
||||
${filteredDiscoveryData.length
|
||||
${discoveryFlows.length
|
||||
? html`
|
||||
<div class="section">
|
||||
<h3 class="section-header">
|
||||
@@ -689,7 +701,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
</h3>
|
||||
<ha-md-list class="discovered">
|
||||
${filteredDiscoveryData.map(
|
||||
${discoveryFlows.map(
|
||||
(flow) =>
|
||||
html`<ha-md-list-item class="discovered">
|
||||
${flow.localized_title}
|
||||
@@ -708,7 +720,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${filteredAttentionFlows.length || filteredAttentionData.length
|
||||
${attentionFlows.length || filteredAttentionData.length
|
||||
? html`
|
||||
<div class="section">
|
||||
<h3 class="section-header">
|
||||
@@ -716,9 +728,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
`ui.panel.config.integrations.integration_page.attention_entries`
|
||||
)}
|
||||
</h3>
|
||||
${filteredAttentionFlows.length
|
||||
${attentionFlows.length
|
||||
? html`<ha-md-list class="attention">
|
||||
${filteredAttentionFlows.map((flow) => {
|
||||
${attentionFlows.map((flow) => {
|
||||
const attention = ATTENTION_SOURCES.includes(
|
||||
flow.context.source
|
||||
);
|
||||
@@ -982,49 +994,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _buildAttentionEntryData = memoizeOne(this._buildEntryData);
|
||||
|
||||
private _normalEntries = memoizeOne(
|
||||
(
|
||||
data: ConfigEntry[],
|
||||
language: HomeAssistant["locale"]["language"]
|
||||
): ConfigEntry[] =>
|
||||
data
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
|
||||
return a.disabled_by ? 1 : -1;
|
||||
}
|
||||
return caseInsensitiveStringCompare(a.title, b.title, language);
|
||||
})
|
||||
);
|
||||
|
||||
private _attentionEntries = memoizeOne((data: ConfigEntry[]): ConfigEntry[] =>
|
||||
data.filter((entry) => ERROR_STATES.includes(entry.state))
|
||||
);
|
||||
|
||||
private _discoveryFlows = memoizeOne(
|
||||
(
|
||||
data: DataEntryFlowProgressExtended[],
|
||||
language: HomeAssistant["locale"]["language"]
|
||||
): DataEntryFlowProgressExtended[] =>
|
||||
data
|
||||
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
|
||||
.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
a.localized_title || "zzz",
|
||||
b.localized_title || "zzz",
|
||||
language
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private _attentionFlows = memoizeOne(
|
||||
(data: DataEntryFlowProgressExtended[]): DataEntryFlowProgressExtended[] =>
|
||||
data.filter((flow) => ATTENTION_SOURCES.includes(flow.context.source))
|
||||
);
|
||||
|
||||
private _filterTree = memoizeOne(
|
||||
(
|
||||
data: ConfigEntryData[],
|
||||
@@ -1120,35 +1089,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this._filterTree(data, filter, areas)
|
||||
);
|
||||
|
||||
private _filterFlowTree = (
|
||||
data: DataEntryFlowProgressExtended[],
|
||||
filter: string
|
||||
): DataEntryFlowProgressExtended[] => {
|
||||
if (!filter) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const TITLE_KEYS = ["localized_title"];
|
||||
|
||||
return multiTermSearch(
|
||||
data.filter((item) => item.localized_title),
|
||||
filter,
|
||||
TITLE_KEYS,
|
||||
undefined,
|
||||
{ keys: TITLE_KEYS }
|
||||
);
|
||||
};
|
||||
|
||||
private _filterDiscoveryTree = memoizeOne(
|
||||
(data: DataEntryFlowProgressExtended[], filter: string) =>
|
||||
this._filterFlowTree(data, filter)
|
||||
);
|
||||
|
||||
private _filterAttentionFlowTree = memoizeOne(
|
||||
(data: DataEntryFlowProgressExtended[], filter: string) =>
|
||||
this._filterFlowTree(data, filter)
|
||||
);
|
||||
|
||||
private _filterAttentionTree = memoizeOne(
|
||||
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
|
||||
this._filterTree(data, filter, areas)
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
const SCHEMA = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "server_port",
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ssl"),
|
||||
schema: [
|
||||
{
|
||||
name: "ssl_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_key",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_peer_certificate",
|
||||
selector: { text: {} },
|
||||
},
|
||||
{
|
||||
name: "ssl_profile",
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "modern",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_modern"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "intermediate",
|
||||
label: localize(
|
||||
"ui.panel.config.network.http.ssl_profile_intermediate"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reverse_proxy",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.reverse_proxy"),
|
||||
schema: [
|
||||
{
|
||||
name: "use_x_forwarded_for",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ip_banning",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.ip_banning"),
|
||||
schema: [
|
||||
{
|
||||
name: "ip_ban_enabled",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "login_attempts_threshold",
|
||||
required: true,
|
||||
selector: { number: { min: -1, max: 1000, mode: "box" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "advanced",
|
||||
type: "expandable",
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "use_x_frame_options",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("ha-config-http-form")
|
||||
class HaConfigHttpForm extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: HttpConfig;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _fieldErrors: Record<string, string> = {};
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@state() private _saved = false;
|
||||
|
||||
protected override firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._fetchConfig();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config && !this._error) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.network.http.caption")}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
${this._saved
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="info"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_required_title"
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart_required_description"
|
||||
)}
|
||||
<ha-button slot="action" @click=${this._restart}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.restart"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing}
|
||||
${this._config
|
||||
? html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${schema}
|
||||
.error=${this._fieldErrors}
|
||||
.disabled=${this._saving}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${this._config
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
<ha-button @click=${this._save} .disabled=${this._saving}>
|
||||
${this.hass.localize("ui.panel.config.network.http.save")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
const result = await fetchHttpConfig(this.hass);
|
||||
this._config = result.config;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
// Expandable sections render their own title; never label them.
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${schema.name}` as any
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
|
||||
): string => {
|
||||
if ("type" in schema && schema.type === "expandable") {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.helpers.${schema.name}` as any
|
||||
) || ""
|
||||
);
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._config = ev.detail.value;
|
||||
this._saved = false;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const form = this.renderRoot.querySelector("ha-form");
|
||||
if (form && !form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
this._saving = true;
|
||||
this._error = undefined;
|
||||
this._fieldErrors = {};
|
||||
try {
|
||||
const result = await saveHttpConfig(this.hass, this._config);
|
||||
this._config = result.config;
|
||||
this._saved = true;
|
||||
} catch (err: any) {
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
await this.updateComplete;
|
||||
const haForm = this.renderRoot.querySelector("ha-form");
|
||||
await haForm?.updateComplete;
|
||||
// Inline field errors render inside ha-form's shadow root, so fall back to
|
||||
// it when no top-level alert is present.
|
||||
const target =
|
||||
this.renderRoot.querySelector<HTMLElement>("ha-alert") ??
|
||||
haForm?.shadowRoot?.querySelector<HTMLElement>("ha-alert");
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
private _restart(): void {
|
||||
showRestartDialog(this);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-http-form": HaConfigHttpForm;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-icon-next";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import "./ha-config-http-form";
|
||||
import "./ha-config-network";
|
||||
import "./ha-config-url-form";
|
||||
import "./supervisor-hostname";
|
||||
@@ -41,7 +40,6 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
<supervisor-network .hass=${this.hass}></supervisor-network>`
|
||||
: ""}
|
||||
<ha-config-url-form .hass=${this.hass}></ha-config-url-form>
|
||||
<ha-config-http-form .hass=${this.hass}></ha-config-http-form>
|
||||
<ha-config-network .hass=${this.hass}></ha-config-network>
|
||||
${NETWORK_BROWSERS.some((component) =>
|
||||
isComponentLoaded(this.hass.config, component)
|
||||
@@ -90,7 +88,6 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
supervisor-hostname,
|
||||
supervisor-network,
|
||||
ha-config-url-form,
|
||||
ha-config-http-form,
|
||||
ha-config-network,
|
||||
.discovery-card {
|
||||
display: block;
|
||||
|
||||
@@ -178,7 +178,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
|
||||
<ha-switch
|
||||
slot="end"
|
||||
@change=${this._allowLoginChanged}
|
||||
?disabled=${this._user &&
|
||||
.disabled=${this._user &&
|
||||
(this._user.id === this.hass.user?.id ||
|
||||
this._user.system_generated ||
|
||||
this._user.is_owner)}
|
||||
|
||||
@@ -92,9 +92,7 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
protected firstUpdated() {
|
||||
this._setLoadingCoordinates();
|
||||
if (this.isConnected) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
this._subscribeHistory();
|
||||
}
|
||||
|
||||
private _setLoadingCoordinates() {
|
||||
@@ -157,7 +155,6 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
this.isConnected &&
|
||||
!this._subscribed &&
|
||||
!this._error &&
|
||||
this._config &&
|
||||
|
||||
@@ -60,21 +60,17 @@ export function getSuggestedMax(
|
||||
noRounding: boolean
|
||||
): Date {
|
||||
// Maximum period depends on whether plotting a line chart or discrete bars.
|
||||
// - For line charts use noRounding true as we must always plot all the way
|
||||
// to end of a given period, otherwise we cut off the last period of data.
|
||||
// - For bar charts with 5minute intervals, leave the full time range
|
||||
// to ensure we don't cut off any bars
|
||||
// - For bar charts of hourly intervals, round to half-period to avoid excess
|
||||
// padding but not cut off the final bar if placed mid interval.
|
||||
// - For bar charts with whole numbers of days we need to round down to the
|
||||
// start of the final bars period to avoid unnecessary padding of the chart.
|
||||
// - For line charts we must be plotting all the way to end of a given period,
|
||||
// otherwise we cut off the last period of data.
|
||||
// - For bar charts we need to round down to the start of the final bars period
|
||||
// to avoid unnecessary padding of the chart.
|
||||
let suggestedMax = new Date(end);
|
||||
|
||||
if (noRounding || period === "5minute") {
|
||||
return suggestedMax;
|
||||
}
|
||||
suggestedMax.setMinutes(0, 0, 0);
|
||||
if (period === "hour") {
|
||||
suggestedMax.setMinutes(30, 0, 0);
|
||||
return suggestedMax;
|
||||
}
|
||||
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
|
||||
@@ -82,7 +78,7 @@ export function getSuggestedMax(
|
||||
if (suggestedMax.getHours() === 0) {
|
||||
suggestedMax = subHours(suggestedMax, 1);
|
||||
}
|
||||
suggestedMax.setHours(0, 0, 0, 0);
|
||||
suggestedMax.setHours(0);
|
||||
if (period === "day" || period === "week") {
|
||||
return suggestedMax;
|
||||
}
|
||||
|
||||
@@ -315,18 +315,14 @@ export class HuiPowerSourcesGraphCard
|
||||
typeof item === "object" && "value" in item!
|
||||
? item.value![0]
|
||||
: item![0];
|
||||
let sum = 0;
|
||||
usageData[i] = [x, 0];
|
||||
this._chartData.forEach((dataset) => {
|
||||
const y =
|
||||
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
|
||||
? dataset.data![i].value![1]
|
||||
: dataset.data![i]![1];
|
||||
sum += y as number;
|
||||
usageData[i]![1] += y as number;
|
||||
});
|
||||
// Consumption can't be negative; sources unaccounted for in the
|
||||
// configuration (e.g. solar exporting to grid without a configured
|
||||
// solar source) would otherwise drag the usage line below zero.
|
||||
usageData[i] = [x, Math.max(0, sum)];
|
||||
});
|
||||
this._chartData.push({
|
||||
...commonSeriesOptions,
|
||||
|
||||
@@ -317,12 +317,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
) {
|
||||
this._unsubscribeHistory();
|
||||
this._subscribeHistory();
|
||||
} else if (
|
||||
this.isConnected &&
|
||||
!this._subscribed &&
|
||||
!this._error &&
|
||||
changedProps.has("hass")
|
||||
) {
|
||||
} else if (!this._subscribed && !this._error && changedProps.has("hass")) {
|
||||
// Retry subscription when components become available after backend restart
|
||||
this._subscribeHistory();
|
||||
}
|
||||
|
||||
@@ -384,10 +384,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
if (this._configEntities?.length) {
|
||||
if (
|
||||
(this.isConnected && !this._subscribed && !this._error) ||
|
||||
changedProps.has("_config")
|
||||
) {
|
||||
if ((!this._subscribed && !this._error) || changedProps.has("_config")) {
|
||||
this._unsubscribeHistory();
|
||||
this._subscribeHistory();
|
||||
}
|
||||
|
||||
@@ -311,12 +311,7 @@ export class HuiGraphHeaderFooter
|
||||
this._unsubscribeHistory();
|
||||
this._subscribeHistory();
|
||||
}
|
||||
} else if (
|
||||
this.isConnected &&
|
||||
!this._subscribed &&
|
||||
!this._error &&
|
||||
changedProps.has("hass")
|
||||
) {
|
||||
} else if (!this._subscribed && !this._error && changedProps.has("hass")) {
|
||||
// Retry subscription when components become available after backend restart
|
||||
this._subscribeHistory();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
|
||||
import type { LovelaceHeadingBadge } from "../types";
|
||||
import type { LovelaceHeadingBadgeConfig } from "./types";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -96,13 +95,6 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("config")) {
|
||||
this._conditionContext = {
|
||||
...this._conditionContext,
|
||||
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this._element) {
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -676,13 +676,6 @@
|
||||
"triggered_by_time_pattern": "triggered by time pattern",
|
||||
"triggered_by_homeassistant_stopping": "triggered by Home Assistant stopping",
|
||||
"triggered_by_homeassistant_starting": "triggered by Home Assistant starting",
|
||||
"numeric_state_of": "numeric state of",
|
||||
"state_of": "state of",
|
||||
"event": "event",
|
||||
"time": "time",
|
||||
"time_pattern": "time pattern",
|
||||
"homeassistant_stopping": "Home Assistant stopping",
|
||||
"homeassistant_starting": "Home Assistant starting",
|
||||
"show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]",
|
||||
"retrieval_error": "Could not load activity",
|
||||
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
|
||||
@@ -5593,7 +5586,6 @@
|
||||
"unsupported_action": "No visual editor support for this action",
|
||||
"type_select": "Action type",
|
||||
"continue_on_error": "Continue on error",
|
||||
"continue_on_error_description": "If this action fails, the next action will still run.",
|
||||
"action": "Action",
|
||||
"copied_to_clipboard": "Action copied to clipboard",
|
||||
"cut_to_clipboard": "Action cut to clipboard",
|
||||
@@ -8251,50 +8243,6 @@
|
||||
"zeroconf": "Zeroconf browser",
|
||||
"zeroconf_info": "Show services discovered using mDNS. Does not include services unknown to Home Assistant."
|
||||
},
|
||||
"http": {
|
||||
"caption": "HTTP server",
|
||||
"description": "Configure how Home Assistant serves its web interface. Changes take effect after a restart.",
|
||||
"save": "Save",
|
||||
"restart": "Restart",
|
||||
"restart_required_title": "Restart required",
|
||||
"restart_required_description": "Restart Home Assistant to apply the new HTTP settings.",
|
||||
"ssl_profile_modern": "Modern",
|
||||
"ssl_profile_intermediate": "Intermediate",
|
||||
"sections": {
|
||||
"ssl": "SSL/TLS",
|
||||
"reverse_proxy": "Reverse proxy",
|
||||
"ip_banning": "IP banning",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"fields": {
|
||||
"server_port": "Server port",
|
||||
"server_host": "Listen addresses",
|
||||
"ssl_certificate": "SSL certificate path",
|
||||
"ssl_key": "SSL key path",
|
||||
"ssl_peer_certificate": "SSL peer certificate path",
|
||||
"ssl_profile": "SSL profile",
|
||||
"cors_allowed_origins": "CORS allowed origins",
|
||||
"use_x_forwarded_for": "Trust X-Forwarded-For",
|
||||
"trusted_proxies": "Trusted proxies",
|
||||
"use_x_frame_options": "Send X-Frame-Options",
|
||||
"ip_ban_enabled": "Enable IP banning",
|
||||
"login_attempts_threshold": "Login attempts before ban"
|
||||
},
|
||||
"helpers": {
|
||||
"server_port": "The port Home Assistant listens on. Default is 8123.",
|
||||
"server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.",
|
||||
"ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).",
|
||||
"ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).",
|
||||
"ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.",
|
||||
"ssl_profile": "Mozilla SSL profile. Use 'Intermediate' only if integrations have SSL handshake issues.",
|
||||
"cors_allowed_origins": "Origins that may make cross-origin requests. Include the scheme, for example https://example.com.",
|
||||
"use_x_forwarded_for": "Trust the X-Forwarded-For header behind a reverse proxy. Requires the trusted proxies list below.",
|
||||
"trusted_proxies": "Reverse-proxy IP addresses or CIDR networks allowed to set X-Forwarded-For. Use a network address, not a host.",
|
||||
"use_x_frame_options": "Send the X-Frame-Options header to help prevent clickjacking.",
|
||||
"ip_ban_enabled": "Automatically ban IP addresses after repeated failed logins.",
|
||||
"login_attempts_threshold": "Failed login attempts before an IP is banned. Set to -1 to disable automatic bans."
|
||||
}
|
||||
},
|
||||
"network_adapter": "Network adapter",
|
||||
"network_adapter_info": "Configure which network adapters integrations will use. A restart is required for these settings to apply.",
|
||||
"ip_information": "IP information",
|
||||
|
||||
@@ -55,11 +55,6 @@ const mockHass: HomeAssistant = {
|
||||
state: "off",
|
||||
attributes: { device_class: "light" },
|
||||
},
|
||||
"binary_sensor.unregistered_battery": {
|
||||
entity_id: "binary_sensor.unregistered_battery",
|
||||
state: "off",
|
||||
attributes: { device_class: "battery" },
|
||||
},
|
||||
} as any,
|
||||
entities: {
|
||||
"light.living_room": {
|
||||
@@ -307,20 +302,6 @@ describe("generateEntityFilter", () => {
|
||||
expect(filter("light.living_room")).toBe(true);
|
||||
expect(filter("sensor.humidity")).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat entities without a registry entry as having no category", () => {
|
||||
const noneFilter = generateEntityFilter(mockHass, {
|
||||
entity_category: "none",
|
||||
});
|
||||
const diagnosticFilter = generateEntityFilter(mockHass, {
|
||||
entity_category: "diagnostic",
|
||||
});
|
||||
|
||||
expect(noneFilter("binary_sensor.unregistered_battery")).toBe(true);
|
||||
expect(diagnosticFilter("binary_sensor.unregistered_battery")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("label filtering", () => {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
localizeTriggerDescription,
|
||||
localizeTriggerSource,
|
||||
} from "../../src/data/logbook";
|
||||
|
||||
const fakeLocalize = ((key: string) => `<${key}>`) as any;
|
||||
|
||||
describe("localizeTriggerSource", () => {
|
||||
it("replaces a known phrase with the prefixed translation", () => {
|
||||
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
|
||||
"<ui.components.logbook.triggered_by_homeassistant_starting>"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves trailing context after the matched phrase", () => {
|
||||
expect(
|
||||
localizeTriggerSource(fakeLocalize, "state of binary_sensor.foo")
|
||||
).toBe("<ui.components.logbook.triggered_by_state_of> binary_sensor.foo");
|
||||
});
|
||||
|
||||
it("returns the source unchanged when no phrase matches", () => {
|
||||
expect(localizeTriggerSource(fakeLocalize, "something else")).toBe(
|
||||
"something else"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeTriggerDescription", () => {
|
||||
it("returns just the bare-phrase translation, without 'triggered by'", () => {
|
||||
expect(
|
||||
localizeTriggerDescription(fakeLocalize, "Home Assistant starting")
|
||||
).toBe("<ui.components.logbook.homeassistant_starting>");
|
||||
});
|
||||
|
||||
it("preserves trailing context after the matched phrase", () => {
|
||||
expect(
|
||||
localizeTriggerDescription(fakeLocalize, "state of binary_sensor.foo")
|
||||
).toBe("<ui.components.logbook.state_of> binary_sensor.foo");
|
||||
});
|
||||
|
||||
it("returns the source unchanged when no phrase matches", () => {
|
||||
expect(localizeTriggerDescription(fakeLocalize, "something else")).toBe(
|
||||
"something else"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* E2E tests for the HA test app (port 8095).
|
||||
*
|
||||
* Run with:
|
||||
* yarn test:e2e:app:local
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The test app is built with __DEMO__=true which enables hash-based routing.
|
||||
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
|
||||
// Scenario selection uses query params: /?scenario=foo (always at root).
|
||||
|
||||
/** Navigate to a panel (hash routing) and wait for app to initialize. */
|
||||
async function goToPanel(page: Page, path: string) {
|
||||
// Paths starting with /? are root-level (scenario selection); panel paths
|
||||
// need to use hash routing (/#/panelname).
|
||||
const url = path.startsWith("/?") ? path : `/#${path}`;
|
||||
await page.goto(url);
|
||||
await page.waitForSelector("ha-test", { state: "attached" });
|
||||
// Wait for the app to finish initialising (hassConnected sets panels)
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("App shell", () => {
|
||||
test("page loads and ha-test element mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders with expected panels", async ({ page }) => {
|
||||
await goToPanel(page, "/");
|
||||
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
|
||||
for (const urlPath of ["lovelace", "energy", "history"]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
|
||||
)
|
||||
).toBeAttached();
|
||||
}
|
||||
// Config has its own special element with id="sidebar-config"
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
)
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test("admin user sees config panel in sidebar", async ({ page }) => {
|
||||
await goToPanel(page, "/");
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
)
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test("non-admin user does NOT see config panel in sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=non-admin");
|
||||
// Config panel is adminOnly — should not appear for non-admin
|
||||
const configLink = page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
);
|
||||
await expect(configLink).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Panel navigation", () => {
|
||||
test("navigates to lovelace dashboard", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(
|
||||
page.locator("ha-panel-lovelace, hui-root").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to energy panel", async ({ page }) => {
|
||||
await goToPanel(page, "/energy");
|
||||
await expect(
|
||||
page.locator("ha-panel-energy, energy-view").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to history panel", async ({ page }) => {
|
||||
await goToPanel(page, "/history");
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to developer-tools panel", async ({ page }) => {
|
||||
// Since 2026.2 developer-tools is part of the config panel
|
||||
await goToPanel(page, "/config/developer-tools");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, developer-tools-main").first()
|
||||
).toBeAttached({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test("navigates to profile panel", async ({ page }) => {
|
||||
await goToPanel(page, "/profile");
|
||||
await expect(
|
||||
page.locator("ha-panel-profile, ha-config-user-profile").first()
|
||||
).toBeAttached({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
await expect(page.locator("hui-card, hui-tile-card").first()).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin user sees edit button", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// The edit FAB / menu button is present for admins
|
||||
await expect(
|
||||
page.locator("[data-testid='edit-mode-button'], ha-menu-button")
|
||||
).toBeAttached({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info dialog (light)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Light more-info dialog", () => {
|
||||
test("opens more-info dialog for a light entity", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=light-more-info");
|
||||
|
||||
// Wait for ha-test to be ready
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
|
||||
// Navigate to lovelace where tiles should appear
|
||||
await page.goto("/#/lovelace");
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
|
||||
// Trigger more-info for our known test entity via JS
|
||||
await page.evaluate(() => {
|
||||
const hass = (window as any).__mockHass;
|
||||
// Build the path dynamically to prevent TypeScript from resolving it
|
||||
// as a local module (it is a runtime URL served by the test app).
|
||||
const dialogPath = ["/frontend_latest", "ha-more-info-dialog.js"].join(
|
||||
"/"
|
||||
);
|
||||
hass.mockEvent("show-dialog", {
|
||||
dialogTag: "ha-more-info-dialog",
|
||||
dialogImport: () =>
|
||||
import(/* @vite-ignore */ dialogPath).catch(() => null),
|
||||
dialogParams: { entityId: "light.test_light" },
|
||||
});
|
||||
// Use the built-in fire event mechanism
|
||||
const el = document.querySelector("ha-test") as any;
|
||||
if (el) {
|
||||
const event = new CustomEvent("hass-more-info", {
|
||||
detail: { entityId: "light.test_light" },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
el.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// The more-info dialog should appear
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Theming", () => {
|
||||
test("dark theme sets darkMode flag", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=dark-theme");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
|
||||
// The dark-theme scenario sets selectedTheme.dark = true, which causes
|
||||
// _applyTheme() to set themes.darkMode = true on the element.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
|
||||
true,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("custom theme applies CSS variables", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=custom-theme");
|
||||
|
||||
// The custom-theme scenario sets --primary-color to #e91e63
|
||||
const primaryColor = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--primary-color"
|
||||
)
|
||||
);
|
||||
expect(primaryColor.trim()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Config panel", () => {
|
||||
test("config panel loads without JS errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/config");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, ha-config-dashboard").first()
|
||||
).toBeAttached({ timeout: 25_000 });
|
||||
|
||||
// Filter known pre-existing errors from vendor code
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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,79 @@
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
|
||||
// ── Individual scenarios ───────────────────────────────────────────────────
|
||||
|
||||
const defaultScenario: Scenario = async (_hass) => {
|
||||
// Default: admin user, light theme — nothing extra to do, ha-test.ts sets
|
||||
// everything up already.
|
||||
};
|
||||
|
||||
const nonAdminScenario: Scenario = async (hass) => {
|
||||
hass.updateHass({
|
||||
user: {
|
||||
...hass.user!,
|
||||
is_admin: false,
|
||||
is_owner: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const darkThemeScenario: Scenario = async (hass) => {
|
||||
// Force dark mode by setting selectedTheme.dark = true.
|
||||
// _applyTheme() reads selectedTheme.dark to determine darkMode; setting
|
||||
// themes.darkMode directly gets overwritten when hassConnected() fires.
|
||||
hass.updateHass({
|
||||
selectedTheme: {
|
||||
theme: hass.selectedTheme?.theme ?? "default",
|
||||
dark: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const customThemeScenario: Scenario = async (hass) => {
|
||||
hass.mockTheme({
|
||||
"primary-color": "#e91e63",
|
||||
"accent-color": "#ff5722",
|
||||
});
|
||||
};
|
||||
|
||||
const historyPanelScenario: Scenario = async (_hass) => {
|
||||
// Navigation happens after hassConnected — handled by Playwright via URL
|
||||
};
|
||||
|
||||
const configPanelScenario: Scenario = async (_hass) => {
|
||||
// Navigation handled by Playwright via URL
|
||||
};
|
||||
|
||||
const lightMoreInfoScenario: Scenario = async (hass) => {
|
||||
// Make sure we have a light entity available (sections config adds them, but
|
||||
// this ensures it exists synchronously for tests that load mid-init).
|
||||
hass.addEntities([
|
||||
{
|
||||
entity_id: "light.test_light",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Test Light",
|
||||
supported_features: 44,
|
||||
supported_color_modes: ["brightness", "color_temp", "xy"],
|
||||
color_mode: "brightness",
|
||||
brightness: 200,
|
||||
min_mireds: 153,
|
||||
max_mireds: 500,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
default: defaultScenario,
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"history-panel": historyPanelScenario,
|
||||
"config-panel": configPanelScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
};
|
||||
@@ -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,249 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// 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" });
|
||||
|
||||
// Dynamic JS imports can fail to load over the BrowserStack tunnel on certain
|
||||
// mobile/browser combos. These are infrastructure errors, not app bugs.
|
||||
const DYNAMIC_IMPORT_ERROR =
|
||||
/error loading dynamically imported module|Importing a module script failed/i;
|
||||
|
||||
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("/");
|
||||
});
|
||||
|
||||
/** Returns only errors that are genuine app bugs (not infra tunnel errors). */
|
||||
function appErrors() {
|
||||
return pageErrors.filter((err) => !DYNAMIC_IMPORT_ERROR.test(err.message));
|
||||
}
|
||||
|
||||
/** True when a dynamic-import network error was recorded. */
|
||||
function hasDynamicImportError() {
|
||||
return pageErrors.some((err) => DYNAMIC_IMPORT_ERROR.test(err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for `locator` to become attached/visible with a fallback for
|
||||
* BrowserStack iOS WebKit which throws "Internal error" on waitForSelector.
|
||||
* When the internal error is thrown, we pause briefly so async pageerror
|
||||
* events can fire, then re-throw only if there was no dynamic-import error.
|
||||
*/
|
||||
async function waitForLocator(
|
||||
page: Page,
|
||||
selector: string,
|
||||
state: "attached" | "visible" = "attached",
|
||||
timeout = 30_000
|
||||
) {
|
||||
try {
|
||||
await page.locator(selector).first().waitFor({ state, timeout });
|
||||
} catch (err) {
|
||||
// Give pageerror listeners a moment to fire.
|
||||
await page.waitForTimeout(1_000);
|
||||
if (hasDynamicImportError()) {
|
||||
// JS chunk failed to load over the tunnel — not an app bug.
|
||||
return "skip" as const;
|
||||
}
|
||||
// BrowserStack iOS WebKit throws "Internal error" on waitForSelector —
|
||||
// treat this as a platform infrastructure issue and skip.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/Internal error/i.test(msg)) {
|
||||
return "skip" as const;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return "ok" as const;
|
||||
}
|
||||
|
||||
// ── 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: 30_000 });
|
||||
|
||||
// The launch screen should disappear once the app is ready
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// No unhandled JS exceptions (excluding infra tunnel errors)
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||
|
||||
test("dashboard renders Lovelace cards", async () => {
|
||||
const page = sharedPage;
|
||||
// Wait for the app shell to be ready
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Lovelace cards are rendered inside the shadow DOM.
|
||||
const viewOrCardSelector = [
|
||||
"hui-masonry-view",
|
||||
"hui-sections-view",
|
||||
"hui-panel-view",
|
||||
"hui-sidebar-view",
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
"hui-glance-card",
|
||||
"hui-button-card",
|
||||
"hui-markdown-card",
|
||||
].join(", ");
|
||||
|
||||
const result = await waitForLocator(page, viewOrCardSelector, "attached");
|
||||
if (result === "skip") {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// At least one card must be visible
|
||||
const cards = page.locator(
|
||||
"hui-tile-card, hui-entity-card, hui-glance-card, hui-button-card, hui-markdown-card"
|
||||
);
|
||||
await expect(cards.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
test("sidebar navigation changes the active panel", async () => {
|
||||
const page = sharedPage;
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
|
||||
// inside a modal drawer that is closed by default. Open it first via
|
||||
// the ha-menu-button in the top app-bar.
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
await menuButton.click();
|
||||
// Wait for the drawer animation to complete so sidebar items are visible.
|
||||
const r1 = await waitForLocator(page, "ha-sidebar", "visible", 15_000);
|
||||
if (r1 === "skip") {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// On wide viewports the sidebar is always rendered.
|
||||
const r1 = await waitForLocator(page, "ha-sidebar", "attached", 30_000);
|
||||
if (r1 === "skip") {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const candidatePanels = ["map", "logbook", "history", "config"];
|
||||
let clicked = false;
|
||||
|
||||
// Wait for at least one panel item to appear before probing visibility.
|
||||
const panelSelector = candidatePanels
|
||||
.map((p) => `#sidebar-panel-${p}`)
|
||||
.join(", ");
|
||||
const r2 = await waitForLocator(page, panelSelector, "visible", 15_000);
|
||||
if (r2 === "skip") {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
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: 15_000,
|
||||
});
|
||||
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: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// 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 clickableCard = page
|
||||
.locator(
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card"
|
||||
)
|
||||
.first();
|
||||
|
||||
const result = await waitForLocator(
|
||||
page,
|
||||
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card",
|
||||
"visible",
|
||||
30_000
|
||||
);
|
||||
if (result === "skip") {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await clickableCard.click();
|
||||
|
||||
// The more-info dialog is a top-level custom element appended to the body.
|
||||
// We verify it is attached, then confirm it rendered by checking the title
|
||||
// span which is slotted into the light DOM and has real layout dimensions.
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
// The title is a slotted <span> in the light DOM — visible and has size.
|
||||
const title = dialog.locator("span.title");
|
||||
await expect(title).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* E2E tests for the HA gallery (port 8100).
|
||||
*
|
||||
* Each component page is tested by navigating to its hash and asserting that
|
||||
* the demo content renders without JS errors and the page element is visible.
|
||||
*
|
||||
* Run with:
|
||||
* yarn test:e2e:gallery:local
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Navigate to a gallery page via hash and wait for it to render. */
|
||||
async function goToGalleryPage(page: Page, hash: string) {
|
||||
// First visit to let ha-gallery boot up
|
||||
await page.goto(`/#${hash}`);
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// Wait for the demo element to appear in ha-gallery's shadow root.
|
||||
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
|
||||
// page-description is only rendered for pages that have a description field,
|
||||
// so we cannot use it as a universal readiness signal.
|
||||
const demoTag = `demo-${hash.replace("/", "-")}`;
|
||||
await page.waitForFunction((tag) => {
|
||||
const gallery = document.querySelector("ha-gallery") as any;
|
||||
return gallery?.shadowRoot?.querySelector(tag) != null;
|
||||
}, demoTag);
|
||||
}
|
||||
|
||||
/** Assert a gallery page loads without console errors.
|
||||
* Demo elements live inside ha-gallery's shadow root — use >> to pierce it.
|
||||
*/
|
||||
async function assertPageLoads(page: Page, hash: string, selector: string) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToGalleryPage(page, hash);
|
||||
|
||||
// Pierce ha-gallery's shadow root with >>
|
||||
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("ResizeObserver") &&
|
||||
!e.includes("Non-Error") &&
|
||||
!e.includes("Extension context") &&
|
||||
!e.includes("this.localize is not a function") &&
|
||||
// Gallery throws plain objects (e.g. from WebSocket/data-fetch) that
|
||||
// show up as "Object" with no stack — not real JS errors.
|
||||
e !== "Object" &&
|
||||
// hui-group-entity-row tries to call .some() on a potentially undefined
|
||||
// entity_id array from mock state data — pre-existing gallery data issue.
|
||||
!e.includes("Cannot read properties of undefined (reading 'some')")
|
||||
);
|
||||
expect(
|
||||
realErrors,
|
||||
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Gallery shell", () => {
|
||||
test("page loads and ha-gallery mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders navigation links", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// The gallery drawer sidebar is inside ha-gallery's shadow root
|
||||
await expect(page.locator("ha-gallery >> mwc-drawer")).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentPages: { name: string; selector: string }[] = [
|
||||
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
||||
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
||||
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
||||
{ name: "ha-button", selector: "demo-components-ha-button" },
|
||||
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
||||
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
||||
{
|
||||
name: "ha-control-circular-slider",
|
||||
selector: "demo-components-ha-control-circular-slider",
|
||||
},
|
||||
{
|
||||
name: "ha-control-number-buttons",
|
||||
selector: "demo-components-ha-control-number-buttons",
|
||||
},
|
||||
{
|
||||
name: "ha-control-select-menu",
|
||||
selector: "demo-components-ha-control-select-menu",
|
||||
},
|
||||
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
||||
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
||||
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
||||
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
||||
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
||||
{
|
||||
name: "ha-expansion-panel",
|
||||
selector: "demo-components-ha-expansion-panel",
|
||||
},
|
||||
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
||||
{ name: "ha-form", selector: "demo-components-ha-form" },
|
||||
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
||||
{
|
||||
name: "ha-hs-color-picker",
|
||||
selector: "demo-components-ha-hs-color-picker",
|
||||
},
|
||||
{ name: "ha-input", selector: "demo-components-ha-input" },
|
||||
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
||||
{ name: "ha-list", selector: "demo-components-ha-list" },
|
||||
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
||||
{
|
||||
name: "ha-progress-button",
|
||||
selector: "demo-components-ha-progress-button",
|
||||
},
|
||||
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
||||
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
||||
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
||||
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
||||
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
||||
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
||||
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
||||
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
||||
{
|
||||
name: "ha-adaptive-dialog",
|
||||
selector: "demo-components-ha-adaptive-dialog",
|
||||
},
|
||||
{
|
||||
name: "ha-adaptive-popover",
|
||||
selector: "demo-components-ha-adaptive-popover",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Components", () => {
|
||||
for (const { name, selector } of componentPages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `components/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const moreInfoPages: { name: string; selector: string }[] = [
|
||||
{ name: "light", selector: "demo-more-info-light" },
|
||||
{ name: "climate", selector: "demo-more-info-climate" },
|
||||
{ name: "cover", selector: "demo-more-info-cover" },
|
||||
{ name: "fan", selector: "demo-more-info-fan" },
|
||||
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
||||
{ name: "input-number", selector: "demo-more-info-input-number" },
|
||||
{ name: "input-text", selector: "demo-more-info-input-text" },
|
||||
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
||||
{ name: "lock", selector: "demo-more-info-lock" },
|
||||
{ name: "media-player", selector: "demo-more-info-media-player" },
|
||||
{ name: "number", selector: "demo-more-info-number" },
|
||||
{ name: "scene", selector: "demo-more-info-scene" },
|
||||
{ name: "timer", selector: "demo-more-info-timer" },
|
||||
{ name: "update", selector: "demo-more-info-update" },
|
||||
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
||||
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
||||
];
|
||||
|
||||
test.describe("More-info dialogs", () => {
|
||||
for (const { name, selector } of moreInfoPages) {
|
||||
test(`more-info ${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `more-info/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace card pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lovelacePages: { name: string; selector: string }[] = [
|
||||
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
||||
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
||||
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
||||
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
||||
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
||||
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
||||
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
||||
{
|
||||
name: "grid-and-stack-card",
|
||||
selector: "demo-lovelace-grid-and-stack-card",
|
||||
},
|
||||
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
||||
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
||||
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
||||
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
||||
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
||||
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
||||
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
||||
{
|
||||
name: "picture-elements-card",
|
||||
selector: "demo-lovelace-picture-elements-card",
|
||||
},
|
||||
{
|
||||
name: "picture-entity-card",
|
||||
selector: "demo-lovelace-picture-entity-card",
|
||||
},
|
||||
{
|
||||
name: "picture-glance-card",
|
||||
selector: "demo-lovelace-picture-glance-card",
|
||||
},
|
||||
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
||||
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
||||
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
||||
];
|
||||
|
||||
test.describe("Lovelace cards", () => {
|
||||
for (const { name, selector } of lovelacePages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `lovelace/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific interaction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Component interactions", () => {
|
||||
test("ha-alert renders all four types", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-alert");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
// The demo uses property binding (.alertType) not attribute binding,
|
||||
// so we verify that multiple ha-alert elements are present.
|
||||
const alerts = demo.locator("ha-alert");
|
||||
await expect(alerts.first()).toBeAttached({ timeout: 10_000 });
|
||||
// There should be at least 4 alerts (one per type)
|
||||
await expect(alerts)
|
||||
.toHaveCount(4, { timeout: 10_000 })
|
||||
.catch(async () => {
|
||||
// If not exactly 4, just verify there are some (demo may include more)
|
||||
const count = await alerts.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-button renders primary action button", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-button");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-control-slider");
|
||||
const demo = page.locator(
|
||||
"ha-gallery >> demo-components-ha-control-slider"
|
||||
);
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-form renders schema-driven fields", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-form");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-form");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-form").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-dialog");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("tile-card renders entity state", async ({ page }) => {
|
||||
await goToGalleryPage(page, "lovelace/tile-card");
|
||||
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("more-info light renders controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/light");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-light");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
// Light more-info should contain a brightness or color-temp control
|
||||
await expect(
|
||||
demo
|
||||
.locator("ha-control-slider, ha-more-info-light, more-info-content")
|
||||
.first()
|
||||
).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("more-info cover renders position controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/cover");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-cover");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("ha-gauge renders a gauge element", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-gauge");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-switch toggles state", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-switch");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
const switchEl = demo.locator("ha-switch").first();
|
||||
await expect(switchEl).toBeAttached({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -31,10 +31,10 @@ describe("getSuggestedMax", () => {
|
||||
assert.equal(result.getTime(), end.getTime());
|
||||
});
|
||||
|
||||
it("rounds down to middle of hour for hour period", () => {
|
||||
it("rounds down to start of hour for hour period", () => {
|
||||
const end = new Date("2024-03-15T14:37:22.000");
|
||||
const result = getSuggestedMax("hour", end, false);
|
||||
assert.equal(result.getMinutes(), 30);
|
||||
assert.equal(result.getMinutes(), 0);
|
||||
assert.equal(result.getSeconds(), 0);
|
||||
assert.equal(result.getMilliseconds(), 0);
|
||||
assert.equal(result.getHours(), 14);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig, configDefaults } from "vitest/config";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, "test/e2e/**"],
|
||||
environment: "jsdom", // to run in browser-like environment
|
||||
env: {
|
||||
TZ: "Etc/UTC",
|
||||
|
||||
+3
-1
@@ -157,5 +157,7 @@
|
||||
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
|
||||
"lit/directives/ref": ["./node_modules/lit/directives/ref.js"]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Exclude e2e tests — they have their own tsconfig that adds node types.
|
||||
"exclude": ["test/e2e"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user