Compare commits

..

53 Commits

Author SHA1 Message Date
Aidan Timson 76189af6a4 Use one dependency cache key 2026-07-13 14:24:45 +01:00
Aidan Timson 18b43014ec Add container-compatible dependency cache 2026-07-13 13:58:21 +01:00
Aidan Timson 5f490c0c66 Share dependencies across E2E jobs 2026-07-13 13:51:39 +01:00
Aidan Timson b5da32f324 Address E2E review findings 2026-07-13 11:26:08 +01:00
Aidan Timson 1cbc60fe86 Limit gallery shell error filtering 2026-07-13 11:04:35 +01:00
Aidan Timson 41cba54094 Keep app shell error checks strict 2026-07-13 11:04:11 +01:00
Aidan Timson a9dc02952b Restore real demo sidebar clicks 2026-07-13 11:03:46 +01:00
Aidan Timson 60c181d5c8 Validate E2E worker overrides 2026-07-13 11:03:20 +01:00
Aidan Timson 86e0536de0 Avoid concurrent cold E2E builds 2026-07-13 09:48:30 +01:00
Aidan Timson c427acffe2 Stabilize demo sidebar navigation test 2026-07-13 09:48:10 +01:00
Aidan Timson 12d4b7207e Clarify local E2E server workflow 2026-07-13 09:32:29 +01:00
Aidan Timson 00b297b0fe Require helper reuse in test changes 2026-07-13 09:30:11 +01:00
Aidan Timson 307bb844fe Keep page error filtering internal 2026-07-13 09:27:49 +01:00
Aidan Timson 5291c37c88 Remove duplicate smoke test parallel mode 2026-07-13 09:27:12 +01:00
Aidan Timson c2e9c5e257 Remove unused route project filtering 2026-07-13 09:26:29 +01:00
Aidan Timson 1ce87aa5d4 Remove ineffective gallery test mode 2026-07-13 09:25:50 +01:00
Aidan Timson 086581d280 Restore demo page error tracking 2026-07-13 09:25:27 +01:00
Aidan Timson 0774f352fb Share parallel e2e smoke registration 2026-07-13 09:05:07 +01:00
Aidan Timson 5ee8f7b4a4 Restore concurrency 2026-07-13 09:05:07 +01:00
Aidan Timson 52788d7f71 Restore concurrency 2026-07-13 09:05:07 +01:00
Aidan Timson 7406cc8cea Avoid duplicate gallery readiness wait 2026-07-13 09:05:07 +01:00
Aidan Timson 2e5731ff82 Run app e2e readiness waits concurrently 2026-07-13 09:05:07 +01:00
Aidan Timson d78e9890bc Avoid slow gallery alert count wait 2026-07-13 09:05:07 +01:00
Aidan Timson 3eaf64bc97 Avoid duplicate gallery page waits 2026-07-13 09:05:07 +01:00
Aidan Timson 1595e3bffc Consolidate config link e2e checks 2026-07-13 09:05:07 +01:00
Aidan Timson df0969a4f5 Run e2e sidebar checks concurrently 2026-07-13 09:05:07 +01:00
Aidan Timson e3c917b65d Remove redundant e2e test cases 2026-07-13 09:05:07 +01:00
Aidan Timson d2ed98613b Use Playwright image for e2e tests 2026-07-13 09:05:07 +01:00
Aidan Timson 6185f5033f Remove local sharded e2e runner 2026-07-13 09:05:07 +01:00
Aidan Timson 78887965ed 4/4/2 sharding 2026-07-13 09:05:07 +01:00
Aidan Timson 2a5e4d5c88 Build demo e2e artifact modern only 2026-07-13 09:05:07 +01:00
Aidan Timson bbfe5b6be8 Build e2e app artifact modern only 2026-07-13 09:05:07 +01:00
Aidan Timson 5c0ef1c653 Fix e2e benchmark concurrency 2026-07-13 09:05:07 +01:00
Aidan Timson 7b64046a2f Use test mode for e2e builds 2026-07-13 09:05:07 +01:00
Aidan Timson e760df5a27 Queue e2e benchmark runs 2026-07-13 09:05:07 +01:00
Aidan Timson 8bafdbcec6 Add local sharded e2e runner 2026-07-13 09:05:07 +01:00
Aidan Timson a20745c274 6,4,6 2026-07-13 09:05:07 +01:00
Aidan Timson 598e0935f6 rm 2026-07-13 09:05:07 +01:00
Aidan Timson a9d560096a Shard demo (2) and gallery (3) 2026-07-13 09:05:07 +01:00
Aidan Timson f1e89688b9 Double sharding count 2026-07-13 09:05:07 +01:00
Aidan Timson 42c40fccdb Format 2026-07-13 09:05:07 +01:00
Aidan Timson b2d2eda50f Fix name 2026-07-13 09:05:07 +01:00
Aidan Timson d37a07b1b4 Fold e2e-local into report job 2026-07-13 09:05:07 +01:00
Aidan Timson 0a74b515ff Shard e2e tests 2026-07-13 09:05:07 +01:00
Aidan Timson e358d1478a Enable workers and full paralelism to tests. Use 60% for local (4 in my case) and 1 for CI 2026-07-13 09:05:07 +01:00
Aidan Timson ac4a4dcd70 Move app e2e helpers into src 2026-07-13 09:05:07 +01:00
Aidan Timson bd70df459b Consolidate app e2e smoke data 2026-07-13 09:05:07 +01:00
Aidan Timson da6f712d2c Split e2e helpers by suite 2026-07-13 09:05:07 +01:00
Aidan Timson 160eaaac4f Tighten app e2e smoke timeouts 2026-07-13 09:05:07 +01:00
Aidan Timson c70498c057 Move app e2e smoke data out of spec 2026-07-13 09:05:07 +01:00
Aidan Timson 1b9841185c Consolidate app route e2e helpers 2026-07-13 09:05:07 +01:00
Aidan Timson d1233489ab Make suites run as parallel jobs in CI. Merge reports after 2026-07-13 09:05:07 +01:00
Aidan Timson 5315c5710f Run e2e suites in parallel 2026-07-13 09:05:07 +01:00
98 changed files with 2915 additions and 4307 deletions
+18 -10
View File
@@ -1,12 +1,18 @@
---
name: ha-frontend-testing
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
---
# HA Frontend Testing
Use this skill when choosing or running validation for frontend changes.
## Test Helpers
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
## Core Commands
```bash
@@ -35,23 +41,25 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
## Playwright E2E
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
Each suite has its own dev server port. Prefer running the relevant server in the background while iterating. Playwright reuses it when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
Start the relevant suite server, then run that suite:
| Suite | Server | Test command |
| ------- | ------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
| Suite | Background server | Test command |
| ------- | -------------------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
Use the same server command with `--status`, `--logs [--follow]`, or `--stop` to manage it. Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
+1 -13
View File
@@ -19,9 +19,6 @@ outputs:
netlify_url:
description: The deployed URL
value: ${{ steps.deploy.outputs.netlify_url }}
unique_deploy_url:
description: The unique hash-based deployment URL for this specific build
value: ${{ steps.deploy.outputs.unique_deploy_url }}
runs:
using: composite
@@ -35,18 +32,9 @@ runs:
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
NETLIFY_SITE_ID: ${{ inputs.site-id }}
run: |
# Execute the deployment
if [ -n "$ALIAS" ]; then
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
else
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
fi
# Collect the urls from the deployment output
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
+21 -1
View File
@@ -8,6 +8,9 @@ inputs:
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache instead of installing
default: "false"
runs:
using: composite
@@ -16,8 +19,25 @@ runs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
cache: ${{ inputs.cache == 'true' && inputs.node-modules-cache != 'true' && 'yarn' || '' }}
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Restore complete dependency tree
if: inputs.node-modules-cache == 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
fail-on-cache-miss: true
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
- name: Install dependencies
if: inputs.node-modules-cache != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
+265 -46
View File
@@ -22,9 +22,8 @@ permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
prepare-dependencies:
name: Prepare dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -32,14 +31,97 @@ jobs:
with:
persist-credentials: false
- name: Check for complete dependency tree
id: dependencies
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: ${{ steps.dependencies.outputs.cache-primary-key }}
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001
defaults:
run:
shell: bash
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for complete dependency tree
id: dependencies
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: ${{ steps.dependencies.outputs.cache-primary-key }}
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: ./.github/actions/build
with:
target: build-demo
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -52,6 +134,7 @@ jobs:
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -59,14 +142,17 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: ./.github/actions/build
with:
target: build-e2e-test-app
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -79,6 +165,7 @@ jobs:
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -86,8 +173,10 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: ./.github/actions/build
@@ -103,41 +192,38 @@ jobs:
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]
# ── Run Playwright tests against Chromium ──────────────────────────────────
e2e-demo:
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-demo
- prepare-container-dependencies
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
shardTotal:
- 2
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
node-modules-cache: true
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -145,36 +231,133 @@ jobs:
name: demo-dist
path: demo/dist/
- name: Run Playwright demo tests
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
path: test/e2e/reports/demo/
if-no-files-found: warn
retention-days: 3
e2e-app:
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-e2e-test-app
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Run Playwright app tests
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
path: test/e2e/reports/app/
if-no-files-found: warn
retention-days: 3
e2e-gallery:
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-gallery
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
- name: Run Playwright gallery tests
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload blob report
- name: Upload gallery blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
name: blob-report-gallery-${{ matrix.shardIndex }}
path: test/e2e/reports/gallery/
if-no-files-found: warn
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local]
needs:
- e2e-demo
- e2e-app
- e2e-gallery
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: ${{ always() }}
permissions:
contents: read
pull-requests: write
@@ -184,15 +367,31 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Download blob report (local)
- name: Download demo blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
path: test/e2e/reports/gallery/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
@@ -209,7 +408,11 @@ jobs:
retention-days: 14
- name: Post report to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
if: >-
github.event_name == 'pull_request' &&
(needs.e2e-demo.result == 'failure' ||
needs.e2e-app.result == 'failure' ||
needs.e2e-gallery.result == 'failure')
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
@@ -217,3 +420,19 @@ jobs:
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
- name: Check suite results
run: |
failed=0
for suite in \
"demo:${{ needs.e2e-demo.result }}" \
"app:${{ needs.e2e-app.result }}" \
"gallery:${{ needs.e2e-gallery.result }}"; do
name="${suite%%:*}"
result="${suite#*:}"
echo "E2E ${name}: ${result}"
if [ "$result" != "success" ]; then
failed=1
fi
done
exit "$failed"
+4 -2
View File
@@ -232,7 +232,7 @@ module.exports.config = {
};
},
demo({ isProdBuild, latestBuild, isStatsBuild }) {
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
return {
name: "demo" + nameSuffix(latestBuild),
entry: {
@@ -247,6 +247,7 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
@@ -306,7 +307,7 @@ module.exports.config = {
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
@@ -321,6 +322,7 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
};
+16
View File
@@ -42,6 +42,22 @@ gulp.task(
)
);
gulp.task(
"build-demo-e2e",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-demo",
"rspack-prod-demo-e2e",
"gen-pages-demo-prod-e2e"
)
);
gulp.task(
"analyze-demo",
gulp.series(
+15
View File
@@ -39,3 +39,18 @@ gulp.task(
"gen-pages-e2e-test-app-prod"
)
);
gulp.task(
"build-e2e-test-app-e2e",
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-e2e",
"gen-pages-e2e-test-app-prod"
)
);
+10
View File
@@ -225,6 +225,16 @@ gulp.task(
)
);
gulp.task(
"gen-pages-demo-prod-e2e",
genPagesProdTask(
DEMO_PAGE_ENTRIES,
paths.demo_dir,
paths.demo_output_root,
paths.demo_output_latest
)
);
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
+1 -1
View File
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.8.0",
version: "5.7.0",
licenseFile: "license-mit",
},
];
+24
View File
@@ -177,6 +177,18 @@ gulp.task("rspack-prod-demo", () =>
bothBuilds(createDemoConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-demo-e2e", () =>
prodBuild(
createDemoConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
@@ -269,6 +281,18 @@ gulp.task("rspack-prod-e2e-test-app", () =>
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
prodBuild(
createE2eTestAppConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
+19 -4
View File
@@ -387,9 +387,14 @@ const createAppConfig = ({
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
);
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
const createDemoConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
createRspackConfig(
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
);
const createCastConfig = ({ isProdBuild, latestBuild }) =>
@@ -401,9 +406,19 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
const createE2eTestAppConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
bundle.config.e2eTestApp({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
})
);
module.exports = {
+2 -11
View File
@@ -1,16 +1,7 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { energyEntities } from "../stubs/entities";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
hass.mockTheme(theme());
return;
}
hass.mockTheme(null, getDemoTheme(theme));
};
import type { DemoConfig } from "./types";
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./sections").then((mod) => mod.demoSections),
@@ -40,5 +31,5 @@ export const setDemoConfig = async (
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
applyDemoTheme(hass, config.theme);
hass.mockTheme(config.theme());
};
+1 -1
View File
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
theme: { theme: "default", dark: false },
theme: () => ({}),
};
+1 -4
View File
@@ -2,9 +2,6 @@ import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoConfig {
index?: number;
@@ -15,5 +12,5 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
theme: DemoTheme;
theme: () => Record<string, string> | null;
}
+4 -2
View File
@@ -5,7 +5,7 @@ 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 { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
@@ -173,7 +173,9 @@ export class HaDemo extends HomeAssistantAppEl {
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
if (conf.theme) {
hass.mockTheme(conf.theme());
}
}
);
+3 -38
View File
@@ -1,37 +1,10 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { ThemeSettings } from "../../../src/types";
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
const THEME_STORAGE_KEY = "demo_theme";
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
export const getDemoTheme = (
fallback: ThemeSettings = DEFAULT_THEME
): ThemeSettings => {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!storedTheme) {
return fallback;
}
try {
return JSON.parse(storedTheme) as ThemeSettings;
} catch {
localStorage.removeItem(THEME_STORAGE_KEY);
return fallback;
}
};
let sidebarChangeCallback;
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", ({ key }) => ({
value: key === "theme" ? getDemoTheme() : null,
}));
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "theme") {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
themeChangeCallback?.({ value });
localStorage.removeItem("selectedTheme");
}
if (key === "sidebar") {
sidebarChangeCallback?.({
value: {
@@ -41,18 +14,10 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
});
}
});
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
if (msg.key === "sidebar") {
sidebarChangeCallback = onChange;
}
if (msg.key === "theme") {
themeChangeCallback = onChange;
onChange?.({
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
onChange?.({ value: null });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
+17 -18
View File
@@ -12,7 +12,7 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:types": "tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
@@ -52,15 +52,15 @@
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.5.0",
"@formatjs/intl-displaynames": "7.3.12",
"@formatjs/intl-durationformat": "0.10.17",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.12",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.3.13",
"@formatjs/intl-pluralrules": "6.3.12",
"@formatjs/intl-relativetimeformat": "12.3.12",
"@formatjs/intl-datetimeformat": "7.4.10",
"@formatjs/intl-displaynames": "7.3.11",
"@formatjs/intl-durationformat": "0.10.16",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.11",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.12",
"@formatjs/intl-pluralrules": "6.3.11",
"@formatjs/intl-relativetimeformat": "12.3.11",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -83,13 +83,13 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@tsparticles/engine": "4.3.1",
"@tsparticles/preset-links": "4.3.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"barcode-detector": "3.2.0",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -107,7 +107,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.11",
"intl-messageformat": "11.2.10",
"js-yaml": "5.2.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -145,7 +145,7 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.64.0",
"@html-eslint/eslint-plugin": "0.63.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
@@ -168,13 +168,12 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:typescript@7.0.2",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.7.0",
"eslint": "10.6.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -207,7 +206,7 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.20",
"tar": "7.5.19",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
+16
View File
@@ -58,6 +58,17 @@
"depNameTemplate": "rhysd/actionlint",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
}
],
"packageRules": [
@@ -86,6 +97,11 @@
"description": "Group date-fns with dependent timezone package",
"groupName": "date-fns",
"matchPackageNames": ["date-fns", "date-fns-tz"]
},
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
}
]
}
+2 -3
View File
@@ -51,9 +51,8 @@ class StorageClass {
storageKey: string,
callback: Callback
): UnsubscribeFunc {
const listeners = this._listeners[storageKey];
if (listeners) {
listeners.push(callback);
if (this._listeners[storageKey]) {
this._listeners[storageKey].push(callback);
} else {
this._listeners[storageKey] = [callback];
}
+5 -4
View File
@@ -33,12 +33,13 @@ const extractVarFromBase = (
varName: string,
baseVars: Record<string, string>
): string | undefined => {
const value = baseVars[varName];
if (value && value.startsWith("var(")) {
const baseVarName = value.substring(6, value.length - 1).trim();
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
const baseVarName = baseVars[varName]
.substring(6, baseVars[varName].length - 1)
.trim();
return extractVarFromBase(baseVarName, baseVars);
}
return value;
return baseVars[varName];
};
/**
+11 -24
View File
@@ -6,10 +6,8 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../common/color/compute-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { stateActive } from "../../common/entity/state_active";
import {
stateColorBrightness,
stateColorCss,
@@ -29,15 +27,10 @@ export class StateBadge extends LitElement {
@property({ attribute: false }) public overrideImage?: string;
/**
* Cannot be a boolean attribute because undefined is treated different than
* false. When it is undefined, state is still colored for light entities.
* @deprecated use `color` instead
*/
// Cannot be a boolean attribute because undefined is treated different than
// false. When it is undefined, state is still colored for light entities.
@property({ attribute: false }) public stateColor?: boolean;
// "state", "none" or a color (theme color name or CSS color). Custom colors
// apply when the entity is active. Takes precedence over stateColor.
@property() public color?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@@ -74,17 +67,11 @@ export class StateBadge extends LitElement {
}
}
private get _color(): string | undefined {
if (this.color) {
return this.color;
}
if (this.stateColor !== undefined) {
return this.stateColor ? "state" : "none";
}
private get _stateColor() {
const domain = this.stateObj
? computeStateDomain(this.stateObj)
: undefined;
return domain === "light" ? "state" : undefined;
return this.stateColor ?? domain === "light";
}
protected render() {
@@ -144,7 +131,6 @@ export class StateBadge extends LitElement {
if (stateObj) {
const domain = computeDomain(stateObj.entity_id);
const color = this._color;
if (this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
@@ -161,10 +147,13 @@ export class StateBadge extends LitElement {
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
} else if (color === "state") {
const stateColor = stateColorCss(stateObj);
if (stateColor) {
iconStyle.color = stateColor;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
@@ -191,8 +180,6 @@ export class StateBadge extends LitElement {
delete iconStyle.color;
}
}
} else if (color && color !== "none" && stateActive(stateObj)) {
iconStyle.color = computeCssColor(color);
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
+2 -4
View File
@@ -1,7 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "../ha-tooltip";
@@ -24,11 +23,10 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
// Inline style because the state-badge color API only colors active entities
return html`<state-badge
.stateObj=${this.stateObj}
.stateColor=${!this.color}
style=${styleMap({ color: this.color })}
.stateColor=${true}
.color=${this.color}
></state-badge>
<div class="info">
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
@@ -1,540 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSectionHeaders(sections)}
.getItems=${this._getItems(sections)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx, sections)
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._i18n.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(
item: string,
idx: number,
sections: ClockDatePartSectionData[]
) {
const label = this._getItemLabel(item, sections);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections(): ClockDatePartSectionData[] {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this._i18n.locale.time_zone,
this._hassConfig.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
this._i18n.locale.language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
private _getSectionHeaders(
sections: ClockDatePartSectionData[]
): { id: string; label: string }[] {
return sections.map((section) => ({
id: section.id,
label: section.title,
}));
}
private _getItems = memoizeOne(
(sections: ClockDatePartSectionData[]) =>
(
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const filteredSections = sections
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
filteredSections.find((candidate) => candidate.id === section)
?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
filteredSections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
}
);
private _getItemLabel(
value: string,
sections: ClockDatePartSectionData[]
): string | undefined {
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}
@@ -181,49 +181,49 @@ export class HaSelectorSelector extends LitElement {
return true;
}
private _schema = memoizeOne((choice: string, localize: LocalizeFunc) => {
const schemas = SELECTOR_SCHEMAS[choice];
return [
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
private _schema = memoizeOne(
(choice: string, localize: LocalizeFunc) =>
[
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
},
},
},
},
...(choice === "manual"
? ([
{
name: "manual",
selector: { object: {} },
},
] as const)
: []),
...(schemas
? schemas.length > 1
? [
...(choice === "manual"
? ([
{
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: schemas,
name: "manual",
selector: { object: {} },
},
]
: schemas
: []),
] as const;
});
] as const)
: []),
...(SELECTOR_SCHEMAS[choice]
? SELECTOR_SCHEMAS[choice].length > 1
? [
{
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: SELECTOR_SCHEMAS[choice],
},
]
: SELECTOR_SCHEMAS[choice]
: []),
] as const
);
protected render() {
let data;
@@ -1,41 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}
@@ -65,7 +65,6 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
+15 -7
View File
@@ -305,10 +305,6 @@ export class HaServiceControl extends LitElement {
) {
return null;
}
const isPrimaryEntity = (entityId: string) => {
const entity = this.hass.entities[entityId];
return !entity?.entity_category && !entity?.hidden;
};
const targetEntities =
ensureArray(
value?.target?.entity_id || value?.data?.entity_id
@@ -337,7 +333,11 @@ export class HaServiceControl extends LitElement {
targetSelector
);
targetDevices.push(...expanded.devices);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
targetEntities.push(primaryEntities);
targetAreas.push(...expanded.areas);
});
@@ -362,7 +362,11 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
targetEntities.push(...primaryEntities);
targetDevices.push(...expanded.devices);
});
@@ -375,7 +379,11 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
targetEntities.push(...primaryEntities);
});
}
+10 -11
View File
@@ -213,14 +213,12 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
entities: T[]
): T | undefined => {
const batteryEntities = entities
.filter((entity) => {
const state = states[entity.entity_id];
return (
state &&
state.attributes.device_class === "battery" &&
.filter(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery" &&
batteryPriorities.includes(computeDomain(entity.entity_id))
);
})
)
.sort(
(a, b) =>
batteryPriorities.indexOf(computeDomain(a.entity_id)) -
@@ -237,10 +235,11 @@ export const findBatteryChargingEntity = <T extends { entity_id: string }>(
states: HomeAssistant["states"],
entities: T[]
): T | undefined =>
entities.find((entity) => {
const state = states[entity.entity_id];
return state && state.attributes.device_class === "battery_charging";
});
entities.find(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery_charging"
);
export const computeEntityRegistryName = (
hass: HomeAssistant,
+3
View File
@@ -17,6 +17,9 @@ export interface HassioStats {
network_tx: number;
}
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
response.data;
export const extractApiErrorMessage = (error: any): string =>
typeof error === "object"
? typeof error.body === "object"
+47 -17
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
type HassioDockerRegistries = Record<
string,
@@ -7,32 +10,59 @@ type HassioDockerRegistries = Record<
export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<HassioDockerRegistries> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
): Promise<HassioDockerRegistries> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET",
"hassio/docker/registries"
)
);
};
export const addHassioDockerRegistry = async (
hass: HomeAssistant,
data: HassioDockerRegistries
) => {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"POST",
"hassio/docker/registries",
data
);
};
export const removeHassioDockerRegistry = async (
hass: HomeAssistant,
registry: string
) => {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
return;
}
await hass.callApi<HassioResponse<void>>(
"DELETE",
`hassio/docker/registries/${registry}`
);
};
+35 -12
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHardwareAudioDevice {
device?: string | null;
@@ -27,18 +30,38 @@ export interface HassioHardwareInfo {
export const fetchHassioHardwareAudio = async (
hass: HomeAssistant
): Promise<HassioHardwareAudioList> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
): Promise<HassioHardwareAudioList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"GET",
"hassio/hardware/audio"
)
);
};
export const fetchHassioHardwareInfo = async (
hass: HomeAssistant
): Promise<HassioHardwareInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
): Promise<HassioHardwareInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET",
"hassio/hardware/info"
)
);
};
+143 -70
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHostInfo {
agent_version: string;
@@ -51,86 +54,156 @@ export interface HostDisksUsage {
export const fetchHassioHostInfo = async (
hass: HomeAssistant
): Promise<HassioHostInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
): Promise<HassioHostInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
}
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
"GET",
"hassio/host/info"
);
return hassioApiResultExtractor(response);
};
export const fetchHassioHassOsInfo = async (
hass: HomeAssistant
): Promise<HassioHassOSInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
): Promise<HassioHassOSInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
}
export const rebootHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET",
"hassio/os/info"
)
);
};
export const shutdownHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
export const rebootHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
}
export const updateOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
};
export const configSyncOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
export const shutdownHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
}
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
export const updateOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
};
export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
}
return hass.callApi<HassioResponse<void>>(
"POST",
"hassio/host/options",
options
);
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
};
export const listDatadisks = async (
hass: HomeAssistant
): Promise<DatadiskList> =>
hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
): Promise<DatadiskList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
}
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HostDisksUsage>>(
"GET",
"hassio/host/disks/default/usage"
)
);
};
+33 -14
View File
@@ -1,6 +1,9 @@
import { getCollection, type Connection } from "home-assistant-js-websocket";
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { supervisorApiWsRequest } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import type { CreateSessionResponse } from "./supervisor";
function setIngressCookie(session: string): string {
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
@@ -10,14 +13,21 @@ function setIngressCookie(session: string): string {
}
export const createHassioSession = async (
hass: Pick<HomeAssistant, "callWS">
hass: HomeAssistant
): Promise<string> => {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
}
const restResponse: { data: { session: string } } = await hass.callApi<
HassioResponse<CreateSessionResponse>
>("POST", "hassio/ingress/session");
return setIngressCookie(restResponse.data.session);
};
export interface IngressPanelInfo {
@@ -40,13 +50,22 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
);
export const validateHassioSession = async (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
session: string
): Promise<void> => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/ingress/validate_session",
{ session }
);
};
+52 -20
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
interface IpConfiguration {
address: string[];
@@ -52,37 +55,66 @@ export interface NetworkInfo {
export const fetchNetworkInfo = async (
hass: HomeAssistant
): Promise<NetworkInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
): Promise<NetworkInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<NetworkInfo>>(
"GET",
"hassio/network/info"
)
);
};
export const updateNetworkInterface = async (
hass: HomeAssistant,
network_interface: string,
options: Partial<NetworkInterface>
) => {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<NetworkInfo>>(
"POST",
`hassio/network/interface/${network_interface}/update`,
options
);
};
export const accesspointScan = async (
hass: HomeAssistant,
network_interface: string
): Promise<AccessPoints> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
): Promise<AccessPoints> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<AccessPoints>>(
"GET",
`hassio/network/interface/${network_interface}/accesspoints`
)
);
};
export const parseAddress = (address: string) => {
const [ip, cidr] = address.split("/");
+19 -6
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, TranslationDict } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
@@ -9,9 +12,19 @@ export interface HassioResolution {
export const fetchHassioResolution = async (
hass: HomeAssistant
): Promise<HassioResolution> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
): Promise<HassioResolution> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioResolution>>(
"GET",
"hassio/resolution/info"
)
);
};
+98 -41
View File
@@ -1,6 +1,8 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -75,6 +77,10 @@ export type HassioPanelInfo = PanelInfo<
}
>;
export interface CreateSessionResponse {
session: string;
}
export interface SupervisorOptions {
channel?: "beta" | "dev" | "stable";
diagnostics?: boolean;
@@ -82,57 +88,99 @@ export interface SupervisorOptions {
}
export const reloadSupervisor = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
};
export const restartSupervisor = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
};
export const updateSupervisor = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
};
export const fetchHassioHomeAssistantInfo = async (
hass: HomeAssistant
): Promise<HassioHomeAssistantInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
): Promise<HassioHomeAssistantInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET",
"hassio/core/info"
)
);
};
export const fetchHassioSupervisorInfo = async (
hass: HomeAssistant
): Promise<HassioSupervisorInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
): Promise<HassioSupervisorInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"GET",
"hassio/supervisor/info"
)
);
};
export const fetchHassioInfo = async (
hass: HomeAssistant
): Promise<HassioInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
): Promise<HassioInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
);
};
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
@@ -215,12 +263,21 @@ export const setSupervisorOption = async (
hass: HomeAssistant,
data: SupervisorOptions
) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/supervisor/options",
data
);
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
+15 -14
View File
@@ -325,34 +325,35 @@ export const getCategoryIcons = async <
domain?: string,
force = false
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
const categoryResources = resources[category];
if (!domain) {
if (!force && categoryResources.all) {
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
if (!force && resources[category].all) {
return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
}
categoryResources.all = getHassIcons(connection, category).then((res) => {
categoryResources.domains = res.resources as any;
resources[category].all = getHassIcons(connection, category).then((res) => {
resources[category].domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
if (!force && domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
}
if (categoryResources.all && !force) {
await categoryResources.all;
if (domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
if (resources[category].all && !force) {
await resources[category].all;
if (domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hassConfig, domain)) {
return undefined;
}
const result = getHassIcons(connection, category, domain);
categoryResources.domains[domain] = result.then(
resources[category].domains[domain] = result.then(
(res) => res?.resources[domain]
) as any;
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
return resources[category].domains[domain] as Promise<CategoryType[T]>;
};
export const getServiceIcons = async (
-5
View File
@@ -7,11 +7,6 @@ export interface CustomPanelConfig {
js_url?: string;
module_url?: string;
html_url?: string;
// When true, the panel takes care of the safe-area insets itself (e.g. it
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
// purpose). Home Assistant then skips adding its own safe-area padding around
// the panel to avoid doubling the insets.
handle_safe_area?: boolean;
}
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
-5
View File
@@ -80,7 +80,6 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
@@ -578,10 +577,6 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
+23 -8
View File
@@ -1,4 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
import { hassioApiResultExtractor } from "../hassio/common";
export interface SupervisorApiCallOptions {
method?: "get" | "post" | "delete";
@@ -10,11 +13,23 @@ export const supervisorApiCall = async <T>(
hass: HomeAssistant,
endpoint: string,
options?: SupervisorApiCallOptions
): Promise<T> =>
hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
): Promise<T> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
// Websockets was added in 2021.2.4
return hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<T>>(
// @ts-ignore
(options.method || "get").toUpperCase(),
`hassio${endpoint}`,
options?.data
)
);
};
+23 -3
View File
@@ -1,12 +1,32 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
await hass.callWS({
type: "hassio/update/core",
backup: backup,
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
data: { backup },
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
backup,
});
};
+10 -9
View File
@@ -80,26 +80,27 @@ class StepFlowMenu extends LitElement {
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
${options.map((option) => {
const optionDescription = optionDescriptions[option];
return html`
${options.map(
(option) => html`
<ha-list-item
hasMeta
.step=${option}
@click=${this._handleStep}
?twoline=${optionDescription}
?multiline-secondary=${optionDescription}
?twoline=${optionDescriptions[option]}
?multiline-secondary=${optionDescriptions[option]}
>
<span>${translations[option]}</span>
${
optionDescription
? html`<span slot="secondary"> ${optionDescription} </span>`
optionDescriptions[option]
? html`<span slot="secondary">
${optionDescriptions[option]}
</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`;
})}
`
)}
</div>
`;
}
@@ -322,8 +322,6 @@ export class DialogHttpPendingConfig
ul {
margin: 0 0 var(--ha-space-4) 0;
padding-left: var(--ha-space-6);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
color: var(--secondary-text-color);
}
li {
-5
View File
@@ -123,10 +123,6 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
payload: { event: string };
}
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
}
interface EMOutgoingMessageAppConfiguration extends EMMessage {
type: "config_screen/show";
}
@@ -205,7 +201,6 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
| EMOutgoingMessageConnectionStatus
| EMOutgoingMessageFrontendLoaded
| EMOutgoingMessageExoplayerPlayHLS
| EMOutgoingMessageExoplayerResize
| EMOutgoingMessageExoplayerStop
+11 -29
View File
@@ -33,12 +33,7 @@ import {
TimeZone,
} from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type {
HomeAssistant,
Resources,
ThemeSettings,
ValuePart,
} from "../types";
import type { HomeAssistant, Resources, ValuePart } from "../types";
import { getLocalLanguage, getTranslation } from "../util/common-translation";
import { demoConfig } from "./demo_config";
import { demoPanels } from "./demo_panels";
@@ -88,10 +83,7 @@ export interface MockHomeAssistant extends HomeAssistant {
loader: () => Promise<unknown>
);
mockEvent(event);
mockTheme(
theme: Record<string, string> | null,
selectedTheme?: ThemeSettings
);
mockTheme(theme: Record<string, string> | null);
formatEntityState(stateObj: HassEntity, state?: string): string;
formatEntityStateToParts(stateObj: HassEntity, state?: string): ValuePart[];
formatEntityAttributeValue(
@@ -511,35 +503,25 @@ export const provideHass = (
},
mockAPI,
mockEvent(event) {
(eventListeners[event] || []).forEach((fn) => {
fn(event);
});
(eventListeners[event] || []).forEach((fn) => fn(event));
},
mockTheme(theme, selectedTheme) {
mockTheme(theme) {
invalidateThemeCache();
selectedTheme ??= {
theme: theme ? "fake-data" : "default",
dark: false,
};
const themeName = selectedTheme.theme;
const darkMode =
selectedTheme.dark ??
matchMedia("(prefers-color-scheme: dark)").matches;
hass().updateHass({
selectedTheme,
selectedTheme: { theme: theme ? "mock" : "default", dark: false },
themes: {
...hass().themes,
darkMode,
theme: themeName,
themes: theme ? { [themeName]: theme as any } : {},
themes: {
mock: theme as any,
},
},
});
const { themes } = hass();
const { themes, selectedTheme } = hass();
applyThemesOnElement(
document.documentElement,
themes,
themeName,
{ ...selectedTheme, dark: darkMode },
selectedTheme!.theme,
{ dark: false },
true
);
},
-1
View File
@@ -120,7 +120,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.render = this.renderHass;
this.update = super.update;
removeLaunchScreen();
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
+15 -62
View File
@@ -53,11 +53,6 @@ class HaPanelApp extends LitElement {
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
// safe-area insets itself. We then stop padding the iframe and forward the
// inset values so the addon can draw into the safe area.
@state() private _handleSafeArea = false;
private _enabledKioskMode = false;
private _sessionKeepAlive?: number;
@@ -93,13 +88,11 @@ class HaPanelApp extends LitElement {
public connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this._handleIframeMessage);
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._handleIframeMessage);
window.removeEventListener("resize", this._handleResize);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
@@ -142,7 +135,6 @@ class HaPanelApp extends LitElement {
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
@@ -187,7 +179,6 @@ class HaPanelApp extends LitElement {
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
this._autoRetryUntil = undefined;
this._fetchData(addon);
}
@@ -425,9 +416,6 @@ class HaPanelApp extends LitElement {
case "home-assistant/subscribe-properties":
this._iframeSubscribeUpdates = true;
// An addon can opt out of the container padding and take care of the
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
if (data.kioskMode && !this.hass.kioskMode) {
this._enabledKioskMode = true;
@@ -437,7 +425,6 @@ class HaPanelApp extends LitElement {
case "home-assistant/unsubscribe-properties":
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
this._enabledKioskMode = false;
@@ -446,38 +433,16 @@ class HaPanelApp extends LitElement {
}
};
// Safe-area insets can change on orientation change; keep a subscribing
// addon in sync.
private _handleResize = () => {
if (this._iframeSubscribeUpdates) {
this._sendPropertiesToIframe();
}
};
private _sendPropertiesToIframe() {
if (!this._iframeRef.value?.contentWindow) {
return;
}
const styles = getComputedStyle(this);
this._iframeRef.value.contentWindow.postMessage(
{
type: "home-assistant/properties",
narrow: this.narrow,
route: this._computeRouteTail(this.route),
// Resolved insets so an addon that handles the safe area itself can
// apply them. Vertical uses the raw insets, horizontal the content
// variables (the docked sidebar already absorbs its side).
safeAreaInsets: {
top: styles.getPropertyValue("--safe-area-inset-top").trim(),
right:
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim(),
bottom: styles.getPropertyValue("--safe-area-inset-bottom").trim(),
left:
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim(),
},
},
"*"
);
@@ -497,38 +462,30 @@ class HaPanelApp extends LitElement {
inset: 0;
}
/* Keep the addon iframe clear of the device safe areas. CSS variables don't
cross the iframe boundary, so this padding on the iframe element is the
only way to inset the embedded document. Vertical uses the raw insets;
horizontal uses the content variables, since the docked sidebar already
absorbs the inset on its side (avoids doubling it). */
iframe {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
border: 0;
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));
}
iframe.loaded {
opacity: 1;
}
/* The addon takes care of the safe area itself (it receives the insets via
postMessage), so drop the container padding to let it draw full-bleed. */
iframe.handle-safe-area {
padding: 0;
.header + iframe {
height: calc(100% - 40px);
}
/* When the header is shown it already covers the top inset. */
.header + iframe {
:host([narrow]) iframe {
padding-top: var(--safe-area-inset-top);
height: calc(100% - var(--safe-area-inset-top, 0px));
}
:host([narrow]) .header + iframe {
padding-top: 0;
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
}
@@ -537,17 +494,8 @@ class HaPanelApp extends LitElement {
display: flex;
align-items: center;
font-size: var(--ha-font-size-l);
height: calc(40px + var(--safe-area-inset-top, 0px));
padding: var(--safe-area-inset-top)
calc(
16px +
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
)
0
calc(
16px +
var(--safe-area-content-inset-left, var(--safe-area-inset-left))
);
height: 40px;
padding: 0 16px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
@@ -557,6 +505,11 @@ class HaPanelApp extends LitElement {
--mdc-icon-size: 20px;
}
:host([narrow]) .header {
height: calc(40px + var(--safe-area-inset-top, 0px));
padding-top: var(--safe-area-inset-top, 0);
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);
@@ -321,10 +321,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
const floorAreas = emptyFloors
? undefined
: this._floorAreas.map((floor, index) => {
const floorEntry = entries[floor.id || `floor${TARGET_SEPARATOR}`];
return index === 0 && !floor.id
? this._renderAreas(floorEntry.areas!)
: this._floorAreas.map((floor, index) =>
index === 0 && !floor.id
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
: this._renderItem(
!floor.id
? this._i18n.localize(
@@ -334,14 +335,19 @@ export default class HaAutomationAddFromTarget extends LitElement {
floor.id || `floor${TARGET_SEPARATOR}`,
!floor.id,
!!floor.id && this._getSelectedTargetId(value) === floor.id,
!floorEntry.open && !!Object.keys(floorEntry.areas!).length,
floorEntry.open,
!entries[floor.id || `floor${TARGET_SEPARATOR}`].open &&
!!Object.keys(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
).length,
entries[floor.id || `floor${TARGET_SEPARATOR}`].open,
this._renderFloorIcon(floor as FloorNestedComboBoxItem),
floorEntry.open
? this._renderAreas(floorEntry.areas!)
entries[floor.id || `floor${TARGET_SEPARATOR}`].open
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
: undefined
);
});
)
);
return html`<ha-section-title
>${this._i18n.localize(
@@ -505,69 +511,81 @@ export default class HaAutomationAddFromTarget extends LitElement {
const items: TemplateResult[] = [];
if (unassignedEntitiesLength) {
const entry = entries[`device${TARGET_SEPARATOR}`];
const open = entries[`device${TARGET_SEPARATOR}`].open;
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.entities"),
`device${TARGET_SEPARATOR}`,
true,
false,
!entry.open,
entry.open,
!open,
open,
undefined,
entry.open
? this._renderDomains(entry.devices!, "entity_")
entries[`device${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`device${TARGET_SEPARATOR}`].devices!,
"entity_"
)
: undefined
)
);
}
if (unassignedHelpersLength) {
const entry = entries[`helper${TARGET_SEPARATOR}`];
const open = entries[`helper${TARGET_SEPARATOR}`].open;
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.helpers"),
`helper${TARGET_SEPARATOR}`,
true,
false,
!entry.open,
entry.open,
!open,
open,
undefined,
entry.open
? this._renderDomains(entry.devices!, "helper_")
entries[`helper${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`helper${TARGET_SEPARATOR}`].devices!,
"helper_"
)
: undefined
)
);
}
if (unassignedDevicesLength) {
const entry = entries[`area${TARGET_SEPARATOR}`];
const open = entries[`area${TARGET_SEPARATOR}`].open;
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.devices"),
`area${TARGET_SEPARATOR}`,
true,
false,
!entry.open,
entry.open,
!open,
open,
undefined,
entry.open ? this._renderDevices(entry.devices!) : undefined
entries[`area${TARGET_SEPARATOR}`].open
? this._renderDevices(entries[`area${TARGET_SEPARATOR}`].devices!)
: undefined
)
);
}
if (unassignedServicesLength) {
const entry = entries[`service${TARGET_SEPARATOR}`];
const open = entries[`service${TARGET_SEPARATOR}`].open;
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.services"),
`service${TARGET_SEPARATOR}`,
true,
false,
!entry.open,
entry.open,
!open,
open,
undefined,
entry.open ? this._renderDevices(entry.devices!) : undefined
entries[`service${TARGET_SEPARATOR}`].open
? this._renderDevices(
entries[`service${TARGET_SEPARATOR}`].devices!
)
: undefined
)
);
}
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name || null,
icon: this._icon || null,
entityId: this._entityId,
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
@@ -214,14 +214,13 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
for (const component of components) {
const componentDomain = component.split(".")[0];
const manifest = manifests[componentDomain];
if (
!entryDomains.has(componentDomain) &&
manifest &&
!manifest.config_flow &&
(!manifest.integration_type ||
manifests[componentDomain] &&
!manifests[componentDomain].config_flow &&
(!manifests[componentDomain].integration_type ||
["device", "hub", "service", "integration"].includes(
manifest.integration_type!
manifests[componentDomain].integration_type!
))
) {
domains.add(componentDomain);
+20 -13
View File
@@ -635,13 +635,16 @@ export class HassioNetwork extends LitElement {
const value = source.value as "disabled" | "auto" | "static";
const version = (source as any).version as "ipv4" | "ipv6";
const iface = this._interface?.[version];
if (!value || !iface || iface.method === value) {
if (
!value ||
!this._interface ||
this._interface[version]!.method === value
) {
return;
}
this._dirty = true;
iface.method = value;
this._interface[version]!.method = value;
this.requestUpdate("_interface");
}
@@ -659,8 +662,7 @@ export class HassioNetwork extends LitElement {
const version = (ev.target as any).version as "ipv4" | "ipv6";
const id = source.id;
const iface = this._interface?.[version];
if (!value || !iface) {
if (!value || !this._interface?.[version]) {
source.reportValidity();
return;
}
@@ -668,26 +670,31 @@ export class HassioNetwork extends LitElement {
this._dirty = true;
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask: oldMask } = parseAddress(iface.address![index]);
const { mask: oldMask } = parseAddress(
this._interface[version].address![index]
);
const { mask } = parseAddress(value);
iface.address![index] = formatAddress(value, mask || oldMask || "");
this._interface[version].address![index] = formatAddress(
value,
mask || oldMask || ""
);
this.requestUpdate("_interface");
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = formatAddress(ip, value);
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
} else if (id === "prefix") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = `${ip}/${value}`;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = `${ip}/${value}`;
this.requestUpdate("_interface");
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
iface.nameservers![index] = value;
this._interface[version].nameservers![index] = value;
this.requestUpdate("_interface");
} else {
iface[id] = value;
this._interface[version][id] = value;
}
}
@@ -326,11 +326,13 @@ class DialogSystemInformation extends LitElement {
const keys: TemplateResult[] = [];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (
domainInfo.info[key] &&
typeof domainInfo.info[key] === "object"
) {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
@@ -361,7 +363,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = infoValue;
value = domainInfo.info[key];
}
keys.push(html`
@@ -429,11 +431,10 @@ class DialogSystemInformation extends LitElement {
];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (domainInfo.info[key] && typeof domainInfo.info[key] === "object") {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (info.type === "pending") {
value = "pending";
@@ -447,7 +448,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = infoValue;
value = domainInfo.info[key];
}
if (first) {
parts.push(`${key} | ${value}\n-- | --`);
-87
View File
@@ -36,11 +36,6 @@ export class HaPanelCustom extends ReactiveElement {
private _wasDisconnected = false;
// Set for embedded-iframe panels that opt out of the container padding via
// `handle_safe_area`; we then inject the resolved insets into the (same
// origin) iframe document so the panel can consume `--safe-area-inset-*`.
private _syncSafeArea = false;
protected createRenderRoot() {
return this;
}
@@ -59,14 +54,10 @@ export class HaPanelCustom extends ReactiveElement {
});
this._setProperties = setProperties;
this.querySelector("iframe")?.classList.add("loaded");
// registerIframe also fires on the iframe's `pageshow`, so this re-applies
// the insets after an internal reload.
this._syncSafeAreaToIframe();
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this._handleResize);
// Only rebuild when reattached after a real disconnect (the 5-minute
// suspendWhenHidden timer in partial-panel-resolver). On first mount,
// update() handles creation via the panel-changed branch, so calling
@@ -79,51 +70,10 @@ export class HaPanelCustom extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._wasDisconnected = true;
this._cleanupPanel();
}
// Safe-area insets can change on orientation change; keep the embedded
// document in sync.
private _handleResize = () => {
this._syncSafeAreaToIframe();
};
private _syncSafeAreaToIframe() {
if (!this._syncSafeArea) {
return;
}
const root =
this.querySelector("iframe")?.contentWindow?.document?.documentElement;
if (!root) {
return;
}
// CSS variables don't cross the iframe boundary, so copy the resolved
// values onto the (same-origin) iframe document. Vertical uses the raw
// insets, horizontal the content variables (the sidebar already absorbs
// its side).
const styles = getComputedStyle(this);
root.style.setProperty(
"--safe-area-inset-top",
styles.getPropertyValue("--safe-area-inset-top").trim()
);
root.style.setProperty(
"--safe-area-inset-bottom",
styles.getPropertyValue("--safe-area-inset-bottom").trim()
);
root.style.setProperty(
"--safe-area-inset-left",
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim()
);
root.style.setProperty(
"--safe-area-inset-right",
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim()
);
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("panel")) {
@@ -151,7 +101,6 @@ export class HaPanelCustom extends ReactiveElement {
private _cleanupPanel() {
delete window.customPanel;
this._setProperties = undefined;
this._syncSafeArea = false;
while (this.lastChild) {
this.removeChild(this.lastChild);
}
@@ -163,30 +112,6 @@ export class HaPanelCustom extends ReactiveElement {
const config = panel.config!._panel_custom;
const panelUrl = getUrl(config);
// Keep the panel content clear of the device safe areas (status bar, home
// indicator, notch). Panels rendered in light DOM inherit the
// `--safe-area-inset-*` variables but most don't consume them, so we apply
// the padding on the container as a safe baseline. The embedded-iframe
// branch below applies the equivalent padding on the iframe instead. Panels
// that manage the safe area themselves can opt out via `handle_safe_area`.
const applySafeArea = !config.handle_safe_area;
// For opted-out embedded-iframe panels we inject the insets into the iframe
// document instead of padding it (see _syncSafeAreaToIframe).
this._syncSafeArea = !applySafeArea && !!config.embed_iframe;
if (applySafeArea && !config.embed_iframe) {
this.style.display = "block";
this.style.boxSizing = "border-box";
// Vertical insets aren't absorbed by any chrome around the panel, so use
// the raw insets. Horizontal uses the (physical) content variables, since
// the sidebar already absorbs the inset on its side (avoids doubling it).
this.style.paddingTop = "var(--safe-area-inset-top)";
this.style.paddingBottom = "var(--safe-area-inset-bottom)";
this.style.paddingLeft =
"var(--safe-area-content-inset-left, var(--safe-area-inset-left))";
this.style.paddingRight =
"var(--safe-area-content-inset-right, var(--safe-area-inset-right))";
}
const tempA = document.createElement("a");
tempA.href = panelUrl.url;
@@ -243,26 +168,14 @@ export class HaPanelCustom extends ReactiveElement {
window.customPanel = this;
const titleAttr = this.panel.title ? `title="${this.panel.title}"` : "";
// Pad the iframe (not the host) with the safe-area insets so the embedded
// document stays clear of the device safe areas. CSS variables don't cross
// the iframe boundary, so this container padding is the baseline; panels
// that handle it themselves opt out via `handle_safe_area`.
const safeAreaPadding = applySafeArea
? `padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));`
: "";
this.innerHTML = `
<style>
iframe {
border: none;
box-sizing: border-box;
width: 100%;
height: 100vh;
height: 100dvh;
display: block;
${safeAreaPadding}
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
+13 -7
View File
@@ -128,13 +128,19 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
${
entitiesSelected
? html`
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
`
: ""
}
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
+1 -4
View File
@@ -49,13 +49,10 @@ class HaPanelIframe extends LitElement {
}
static styles = css`
/* Fill hass-subpage's content box, which already excludes the safe-area
insets (see hass-subpage .content), instead of positioning absolutely
and spilling into the bottom/side insets. */
iframe {
border: 0;
display: block;
width: 100%;
position: absolute;
height: 100%;
background-color: var(--primary-background-color);
}
+9 -43
View File
@@ -1,4 +1,4 @@
import { mdiFilterRemove, mdiRefresh } from "@mdi/js";
import { mdiRefresh } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
@@ -16,7 +16,6 @@ import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-icon-button";
import "../../components/ha-target-picker";
@@ -28,11 +27,6 @@ import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
}
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,7 +54,14 @@ export class HaPanelLogbook extends LitElement {
public constructor() {
super();
this._time = this._defaultState.time;
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
this._time = { range: [start, end] };
}
protected render() {
@@ -70,13 +71,6 @@ export class HaPanelLogbook extends LitElement {
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-icon-button
slot="actionItems"
@click=${this._refreshLogbook}
@@ -236,34 +230,6 @@ export class HaPanelLogbook extends LitElement {
);
}
private get _defaultState(): LogbookState {
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
return {
time: { range: [start, end] },
targetPickerValue: {},
};
}
private _isDefaultState(): boolean {
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
);
}
private _resetLogbook() {
const defaultState = this._defaultState;
this._time = defaultState.time;
this._targetPickerValue = defaultState.targetPickerValue;
this._storedTargetPickerValue = undefined;
navigate("/logbook", { replace: true });
}
private _refreshLogbook() {
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
}
@@ -1,243 +0,0 @@
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* Resolves the locale and time zone for a clock card from `hass` and the
* card's configuration. Applies the optional `time_format` override to the
* locale and falls back to the user's preferred time zone.
*/
export const resolveClockCardLocale = (
hass: HomeAssistant,
config: Pick<ClockCardConfig, "time_format" | "time_zone">
): { locale: HomeAssistant["locale"]; timeZone: string } => {
const locale = config.time_format
? { ...hass.locale, time_format: config.time_format }
: hass.locale;
const timeZone =
config.time_zone ||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
return { locale, timeZone };
};
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};
@@ -2,17 +2,9 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import "../../../../components/ha-marquee-text";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -34,11 +26,6 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -53,18 +40,42 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
@state() private _date?: string;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
private _dateInterval?: number;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _timeZone?: string;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _language?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._initDate();
this._computeOffsets();
}
public disconnectedCallback() {
@@ -73,80 +84,18 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const parts = this._dateTimeFormat.formatToParts();
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -154,7 +103,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = date.getMilliseconds();
const ms = new Date().getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -164,45 +113,16 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const dateLines = this._date?.split("\n") ?? [];
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const indicator = (number?: number) => html`
<div
@@ -248,14 +168,14 @@ export class HuiClockCardAnalog extends LitElement {
>
${
this.config.ticks === "quarter"
? QUARTER_TICKS.map(
? Array.from({ length: 4 }, (_, i) => i).map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
style=${`--tick-rotation: ${i * 90}deg;`}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -263,30 +183,28 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? HOUR_TICKS.map(
? Array.from({ length: 12 }, (_, i) => i).map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
style=${`--tick-rotation: ${i * 30}deg;`}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? MINUTE_TICKS.map(
? Array.from({ length: 60 }, (_, i) => i).map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
style=${`--tick-rotation: ${i * 6}deg;`}
>
${
i % 5 === 0
@@ -298,43 +216,14 @@ export class HuiClockCardAnalog extends LitElement {
)
: nothing
}
${
showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${dateLines.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="5"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>`
: nothing
}
<div class="center-dot"></div>
<div
class="hand hour"
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
></div>
<div
class="hand minute"
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
></div>
${
this.config.show_seconds
@@ -344,13 +233,11 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${styleMap({
"animation-delay": `-${
this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)
}s`,
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
></div>`
: nothing
}
@@ -530,42 +417,6 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.long-date:not(.size-medium):not(.size-large) {
top: 66%;
font-size: var(--ha-font-size-xs);
line-height: 1;
width: 45%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}
@@ -1,16 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-marquee-text";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
const INTERVAL = 1000;
@@ -30,50 +24,37 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
let locale = this.hass?.locale;
if (this.config?.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone,
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("config") || changedProps.has("hass")) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
@@ -90,7 +71,6 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -105,8 +85,7 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const parts = this._dateTimeFormat.formatToParts();
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -114,33 +93,6 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -149,65 +101,28 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
</div>
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
</div>
${
showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="10"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>
</div>`
: nothing
}
`;
}
static styles = css`
:host {
display: block;
width: 100%;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
@@ -277,29 +192,6 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
margin-inline: auto;
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}
@@ -226,24 +226,21 @@ export function generatePowerSourcesGraphData(
// Draw in reverse order so 0 value lines are overwritten
["solar", "battery", "grid"].forEach((key, i) => {
const series = seriesData[key];
if (series) {
pushSeries(key, series.positive, "positive", 3 - i);
if (seriesData[key]) {
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
}
});
// Draw in reverse order but above positive series
["battery", "grid"].forEach((key, i) => {
const series = seriesData[key];
if (series) {
pushSeries(key, series.negative, "negative", 4 - i);
if (seriesData[key]) {
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
}
});
Object.keys(statIds).forEach((key) => {
const series = seriesData[key];
if (series) {
const { colorHex, rgb } = series;
if (seriesData[key]) {
const { colorHex, rgb } = seriesData[key];
legendData!.push({
id: key,
+39 -16
View File
@@ -8,7 +8,6 @@ import "../../../components/ha-card";
import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { findEntities } from "../common/find-entities";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-entities-toggle";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
@@ -24,8 +23,7 @@ import type {
LovelaceGridOptions,
LovelaceHeaderFooter,
} from "../types";
import { migrateEntitiesCardConfig } from "./migrate-card-config";
import type { EntitiesCardConfig, EntitiesCardEntityConfig } from "./types";
import type { EntitiesCardConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
export const computeShowHeaderToggle = <
@@ -51,7 +49,32 @@ export const computeShowHeaderToggle = <
return !!config.show_header_toggle;
};
export { migrateEntitiesCardConfig };
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
@@ -160,7 +183,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
const migratedConfig = migrateEntitiesCardConfig(config);
const entities = processConfigEntities(migratedConfig.entities);
this._config = { color: "state", ...migratedConfig };
this._config = migratedConfig;
this._configEntities = entities;
this._showHeaderToggle = computeShowHeaderToggle(migratedConfig, entities);
if (this._config.header) {
@@ -344,18 +367,18 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
`,
];
private _rowConfig(entityConf: LovelaceRowConfig): LovelaceRowConfig {
if (entityConf.type === "perform-action") {
return { ...entityConf, type: "call-service" };
}
return applyDefaultColor(
entityConf as EntitiesCardEntityConfig,
this._config!.color
);
}
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(this._rowConfig(entityConf));
const element = createRowElement(
(!("type" in entityConf) || entityConf.type === "conditional") &&
"state_color" in this._config!
? ({
state_color: this._config.state_color,
...(entityConf as EntityConfig),
} as EntityConfig)
: entityConf.type === "perform-action"
? { ...entityConf, type: "call-service" }
: entityConf
);
if (this._hass) {
element.hass = this._hass;
}
+34 -14
View File
@@ -26,17 +26,40 @@ import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../components/hui-warning-element";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { migrateGlanceCardConfig } from "./migrate-card-config";
import type { GlanceCardConfig, GlanceConfigEntity } from "./types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
export { migrateGlanceCardConfig };
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
@customElement("hui-glance-card")
export class HuiGlanceCard extends LitElement implements LovelaceCard {
@@ -88,19 +111,14 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
show_name: true,
show_state: true,
show_icon: true,
color: "state",
state_color: true,
...migratedConfig,
};
const cardColor = this._config.color;
const entities = processConfigEntities(migratedConfig.entities).map(
(entityConf) =>
applyDefaultColor(
{
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
},
cardColor
)
(entityConf) => ({
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
})
);
for (const entity of entities) {
@@ -300,7 +318,9 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj}
.overrideIcon=${entityConf.icon}
.overrideImage=${entityConf.image}
.color=${entityConf.color}
.stateColor=${
entityConf.state_color ?? this._config!.state_color
}
></state-badge>
`
: ""
@@ -1,81 +0,0 @@
import { migrateStateColorConfig } from "../common/entity-color-config";
import { migrateTimeFormatConfig } from "../common/entity-time-format-config";
import type {
ConditionalRowConfig,
LovelaceRowConfig,
} from "../entity-rows/types";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
GlanceCardConfig,
GlanceConfigEntity,
} from "./types";
const migrateEntitiesRowConfig = (
rowConf: LovelaceRowConfig | string
): LovelaceRowConfig | string => {
if (typeof rowConf !== "object") {
return rowConf;
}
let newConf: LovelaceRowConfig = rowConf;
newConf = migrateTimeFormatConfig(newConf as EntitiesCardEntityConfig);
newConf = migrateStateColorConfig(newConf as EntitiesCardEntityConfig);
if (newConf.type === "conditional") {
const row = (newConf as ConditionalRowConfig).row;
if (row && typeof row === "object") {
let newRow = migrateTimeFormatConfig(row as EntitiesCardEntityConfig);
newRow = migrateStateColorConfig(newRow);
if (newRow !== row) {
newConf = { ...newConf, row: newRow } as ConditionalRowConfig;
}
}
}
return newConf;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
const newConf = migrateEntitiesRowConfig(e);
if (newConf !== e) {
changed = true;
}
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return newConfig;
}
return {
...newConfig,
entities: newEntities,
};
};
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
let newConf = e;
newConf = migrateTimeFormatConfig(newConf);
newConf = migrateStateColorConfig(newConf);
if (newConf !== e) {
changed = true;
}
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return newConfig;
}
return {
...newConfig,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
-25
View File
@@ -112,9 +112,7 @@ export interface EntitiesCardEntityConfig extends EntityConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
show_name?: boolean;
show_icon?: boolean;
}
@@ -128,9 +126,7 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
icon?: string;
header?: LovelaceHeaderFooterConfig;
footer?: LovelaceHeaderFooterConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export type AreaCardDisplayType = "compact" | "icon" | "picture" | "camera";
@@ -334,9 +330,7 @@ export interface GlanceConfigEntity extends ConfigEntity {
show_last_changed?: boolean;
image?: string;
show_state?: boolean;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
time_format?: TimestampRenderingFormat;
}
@@ -348,9 +342,7 @@ export interface GlanceCardConfig extends LovelaceCardConfig {
theme?: string;
entities: (string | GlanceConfigEntity)[];
columns?: number;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export interface HumidifierCardConfig extends LovelaceCardConfig {
@@ -453,29 +445,12 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
@@ -1,34 +0,0 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
interface LegacyStateColorConfig {
type?: string;
color?: string;
state_color?: boolean;
}
export const migrateStateColorConfig = <T extends LegacyStateColorConfig>(
config: T
): T => {
// Custom elements own their config schema, leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.state_color === undefined) {
return config;
}
const { state_color, ...rest } = config;
return {
color: state_color ? "state" : "none",
...rest,
} as T;
};
export const applyDefaultColor = <T extends { type?: string; color?: string }>(
config: T,
color: string | undefined
): T =>
color === undefined ||
config.color !== undefined ||
(config.type !== undefined && isCustomType(config.type))
? config
: { ...config, color };
@@ -1,27 +0,0 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
import type { TimestampRenderingFormat } from "../components/types";
interface LegacyTimeFormatConfig {
type?: string;
time_format?: TimestampRenderingFormat;
/** @deprecated use `time_format` instead */
format?: TimestampRenderingFormat;
}
export const migrateTimeFormatConfig = <T extends LegacyTimeFormatConfig>(
config: T
): T => {
// Custom elements own their config schema and may use the same option with
// a different meaning (e.g. custom:multiple-entity-row), leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.format === undefined) {
return config;
}
const { format, ...rest } = config;
return {
time_format: format,
...rest,
} as T;
};
@@ -86,7 +86,6 @@ export class HuiGenericEntityRow extends LitElement {
.overrideIcon=${this.config.icon}
.overrideImage=${this.config.image}
.stateColor=${this.config.state_color}
.color=${this.config.color}
></state-badge>
${
!this.hideName
@@ -250,14 +250,18 @@ export class HuiBadgePicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._loadBages();
}
@@ -274,14 +274,18 @@ export class HuiCardPicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._loadCards();
}
@@ -2,15 +2,16 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
@@ -18,47 +19,58 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
import {
CLOCK_CARD_DATE_PARTS,
getClockCardDateConfig,
} from "../../cards/clock/clock-date-format";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
title: optional(string()),
clock_style: optional(enums(["digital", "analog"])),
clock_size: optional(enums(["small", "medium", "large"])),
clock_style: optional(union([literal("digital"), literal("analog")])),
clock_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
defaulted(
union([
literal("none"),
literal("quarter"),
literal("hour"),
literal("minute"),
]),
literal("hour")
)
),
seconds_motion: optional(
defaulted(enums(["continuous", "tick"]), "continuous")
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
),
face_style: optional(
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
defaulted(
union([
literal("markers"),
literal("numbers_upright"),
literal("roman"),
]),
literal("markers")
)
),
})
);
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
time_format?: ClockCardConfig["time_format"] | "auto";
};
@customElement("hui-clock-card-editor")
export class HuiClockCardEditor
extends LitElement
@@ -81,7 +93,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "box",
mode: "dropdown",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -107,13 +119,6 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
...(clockStyle === "digital"
? ([
{
@@ -236,28 +241,18 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
const dateConfig = getClockCardDateConfig(config);
const data: ClockCardFormData = {
...config,
clock_style: config.clock_style ?? "digital",
clock_size: config.clock_size ?? "small",
time_format: config.time_format ?? "auto",
show_seconds: config.show_seconds ?? false,
no_background: config.no_background ?? false,
// Analog clock options
border: config.border ?? false,
ticks: config.ticks ?? "hour",
face_style: config.face_style ?? "markers",
};
if (config.date_format === undefined) {
data.date_format = dateConfig.parts;
}
return data;
});
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
// Analog clock options
border: false,
ticks: "hour",
face_style: "markers",
...config,
}));
public setConfig(config: ClockCardConfig): void {
assert(config, cardConfigStruct);
@@ -286,40 +281,35 @@ export class HuiClockCardEditor
`;
}
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
const config = ev.detail.value;
if (!config.date_format || config.date_format.length === 0) {
delete config.date_format;
private _valueChanged(ev: CustomEvent): void {
if (ev.detail.value.time_format === "auto") {
delete ev.detail.value.time_format;
}
if (config.time_format === "auto") {
delete config.time_format;
}
if (config.clock_style === "analog") {
config.border = config.border ?? false;
config.ticks = config.ticks ?? "hour";
config.face_style = config.face_style ?? "markers";
if (config.show_seconds) {
config.seconds_motion = config.seconds_motion ?? "continuous";
if (ev.detail.value.clock_style === "analog") {
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
} else {
delete config.seconds_motion;
delete ev.detail.value.seconds_motion;
}
} else {
delete config.border;
delete config.ticks;
delete config.face_style;
delete config.seconds_motion;
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
}
if (config.ticks !== "none") {
config.face_style = config.face_style ?? "markers";
if (ev.detail.value.ticks !== "none") {
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
} else {
delete config.face_style;
delete ev.detail.value.face_style;
}
fireEvent(this, "config-changed", { config });
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
@@ -354,10 +344,6 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -383,10 +369,6 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`
@@ -22,9 +22,11 @@ import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { customType } from "../../../../common/structs/is-custom-type";
import "../../../../components/ha-card";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-theme-picker";
import "../../../../components/input/ha-input";
import { isCustomType } from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import {
@@ -88,7 +90,6 @@ const conditionalEntitiesRowConfigStruct = object({
type: literal("conditional"),
row: any(),
conditions: array(any()),
color: optional(string()),
});
const dividerEntitiesRowConfigStruct = object({
@@ -180,22 +181,6 @@ const entitiesRowConfigStruct = dynamic<any>((value) => {
return entitiesConfigStruct;
});
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
{ name: "show_header_toggle", selector: { boolean: {} } },
] as const;
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -204,7 +189,7 @@ const cardConfigStruct = assign(
theme: optional(string()),
icon: optional(string()),
show_header_toggle: optional(boolean()),
color: optional(string()),
state_color: optional(boolean()),
entities: array(entitiesRowConfigStruct),
header: optional(headerFooterConfigStructs),
footer: optional(headerFooterConfigStructs),
@@ -241,6 +226,14 @@ export class HuiEntitiesCardEditor
);
});
get _title(): string {
return this._config!.title || "";
}
get _theme(): string {
return this._config!.theme || "";
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -258,20 +251,52 @@ export class HuiEntitiesCardEditor
`;
}
const data = {
...this._config,
show_header_toggle: this._showHeaderToggle(this._config),
};
return html`
<div class="card-config">
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._formChanged}
></ha-form>
<ha-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.title"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._title}
.configValue=${"title"}
@input=${this._valueChanged}
></ha-input>
<ha-theme-picker
.value=${this._theme}
.label=${`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></ha-theme-picker>
<div class="side-by-side">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
)}
>
<ha-switch
.checked=${this._showHeaderToggle(this._config)}
.configValue=${"show_header_toggle"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.state_color"
)}
>
<ha-switch
.checked=${this._config!.state_color}
.configValue=${"state_color"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
</div>
<hui-header-footer-editor
.hass=${this.hass}
.configValue=${"header"}
@@ -296,43 +321,6 @@ export class HuiEntitiesCardEditor
`;
}
private _formChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config) {
return;
}
const config = { ...ev.detail.value } as EntitiesCardConfig;
if (
this._config.show_header_toggle === undefined &&
config.show_header_toggle === this._showHeaderToggle(this._config)
) {
delete config.show_header_toggle;
}
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "title":
case "theme":
return `${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "show_header_toggle":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
@@ -342,7 +330,17 @@ export class HuiEntitiesCardEditor
const target = ev.target! as EditorTarget;
const configValue =
target.configValue || this._subElementEditorConfig?.type;
const value = ev.detail.config ?? ev.detail.value;
const value =
target.checked !== undefined
? target.checked
: target.value || ev.detail.config || ev.detail.value;
if (
(configValue === "title" && target.value === this._title) ||
(configValue === "theme" && target.value === this._theme)
) {
return;
}
if (configValue === "row" || (ev.detail && ev.detail.entities)) {
const newConfigEntities =
@@ -361,7 +359,7 @@ export class HuiEntitiesCardEditor
this._config = { ...this._config!, entities: newConfigEntities };
this._configEntities = processEditorEntities(this._config!.entities);
} else if (configValue) {
if (value === "" || value === undefined) {
if (value === "") {
this._config = { ...this._config };
delete this._config[configValue!];
} else {
@@ -437,6 +435,10 @@ export class HuiEntitiesCardEditor
hui-header-footer-editor {
padding-top: var(--ha-space-1);
}
ha-input {
--ha-input-padding-bottom: var(--ha-space-4);
}
`,
];
}
@@ -59,28 +59,13 @@ export class HuiGenericEntityRowEditor
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
],
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{
name: "secondary_info",
@@ -44,7 +44,7 @@ const cardConfigStruct = assign(
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
color: optional(string()),
state_color: optional(boolean()),
entities: array(entitiesConfigStruct),
})
);
@@ -69,16 +69,7 @@ const SCHEMA = [
{ name: "show_state", selector: { boolean: {} } },
],
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
{ name: "state_color", selector: { boolean: {} } },
] as const;
@customElement("hui-glance-card-editor")
@@ -124,15 +115,6 @@ export class HuiGlanceCardEditor
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
@@ -1,4 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -261,10 +260,9 @@ export class HuiHistoryGraphCardEditor
const domain = computeDomain(entityId);
const isNumberDomain =
domain === "counter" || domain === "number" || domain === "input_number";
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
const stateObj = this.hass!.states[entityId];
const attributes = stateObj?.attributes;
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
return !isNumeric && !isNumberDomain;
return !isNumericFromAttributes(attributes) && !isNumberDomain;
};
// remove "color" option when needed
@@ -14,7 +14,7 @@ export const entitiesConfigStruct = union([
image: optional(string()),
secondary_info: optional(string()),
time_format: optional(timeFormatConfigStruct),
color: optional(string()),
state_color: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
@@ -1,9 +1,12 @@
import { customElement } from "lit/decorators";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { applyDefaultColor } from "../common/entity-color-config";
import type { EntityCardConfig } from "../cards/types";
import { HuiConditionalBase } from "../components/hui-conditional-base";
import { createRowElement } from "../create-element/create-row-element";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
import type {
ConditionalRowConfig,
EntityConfig,
LovelaceRow,
} from "../entity-rows/types";
import { fireEvent } from "../../../common/dom/fire_event";
declare global {
@@ -20,10 +23,13 @@ class HuiConditionalRow extends HuiConditionalBase implements LovelaceRow {
throw new Error("No row configured");
}
const { color } = config as EntitiesCardEntityConfig;
this._element = createRowElement(
applyDefaultColor(config.row as EntitiesCardEntityConfig, color)
(config as EntityCardConfig).state_color
? ({
state_color: true,
...(config.row as EntityConfig),
} as EntityConfig)
: config.row
) as LovelaceRow;
}
+9 -12
View File
@@ -608,24 +608,22 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.params || {})) {
const value = params[key];
if (!value && type.endsWith("?")) {
if (!params[key] && type.endsWith("?")) {
continue;
}
if (!value || !this._checkParamType(type, value)) {
if (!params[key] || !this._checkParamType(type, params[key])) {
throw Error();
}
resultParams[key] = value;
resultParams[key] = params[key];
}
for (const [key, type] of Object.entries(
this._redirect!.optional_params || {}
)) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
throw Error();
}
resultParams[key] = value;
resultParams[key] = params[key];
}
}
return Object.keys(resultParams).length
@@ -639,12 +637,11 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.optional_params)) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
throw Error();
}
resultParams[key] = value;
resultParams[key] = params[key];
}
}
return Object.keys(resultParams).length
-27
View File
@@ -10140,33 +10140,6 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Select and order the date parts. Add a separator to control punctuation.",
"sections": {
"weekday": "Weekday",
"day": "Day",
"month": "Month",
"year": "Year",
"separator": "Separator"
},
"parts": {
"weekday-short": "Short",
"weekday-long": "Long",
"day-numeric": "Numeric",
"day-2-digit": "2-digit",
"month-short": "Short",
"month-long": "Long",
"month-numeric": "Numeric",
"month-2-digit": "2-digit",
"year-2-digit": "2-digit",
"year-numeric": "Full",
"separator-dash": "Dash",
"separator-slash": "Slash",
"separator-dot": "Dot",
"separator-new-line": "New line"
}
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
+67 -463
View File
@@ -4,164 +4,29 @@
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
import { e2ePanelRouteAssertions } from "./app/src/ha-test-panels";
/**
* Each More info view renders one root element inside the dialog, plus one or
* more characteristic descendants that prove the view actually populated rather
* than rendering an empty shell. `text`, when set, asserts the element's text
* instead of just its presence.
*/
const MORE_INFO_VIEW_ELEMENTS: {
view: MoreInfoView;
element: string;
content: { selector: string; text?: string }[];
}[] = [
{
view: "info",
element: "ha-more-info-info",
content: [
{ selector: "more-info-light" },
{ selector: "span.title", text: "Test Light" },
],
},
{
view: "history",
element: "ha-more-info-history-and-logbook",
// The demo loads the history component but not logbook.
content: [{ selector: "ha-more-info-history" }],
},
{
view: "settings",
element: "ha-more-info-settings",
// The scenario mocks config/entity_registry/get, so the real registry
// panel renders instead of the "no unique ID" warning.
content: [{ selector: "entity-registry-settings" }],
},
{
view: "related",
element: "ha-related-items",
// search/related is mocked to return no relations, so the empty list
// renders.
content: [{ selector: "ha-related-items >> ha-list" }],
},
{
view: "add_to",
element: "ha-more-info-add-to",
// Admin users get the default add-to action list.
content: [{ selector: "ha-add-to-action-list" }],
},
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
const URL_NORMALIZATION_ASSERTIONS: {
name: string;
path: string;
element: string;
url: RegExp;
action?: (page: Page) => Promise<void>;
}[] = [
{
name: "keeps the todo panel when adding the selected entity query",
path: "/todo",
element: "ha-panel-todo",
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
},
{
name: "keeps the history panel when removing the back query",
path: "/?back=1#/history",
element: "ha-panel-history, history-panel",
url: /\/#\/history$/,
},
{
name: "keeps the logbook panel when removing the back query",
path: "/?back=1#/logbook",
element: "ha-panel-logbook",
url: /\/#\/logbook$/,
},
{
name: "keeps the lovelace panel when adding the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/\?edit=1#\/lovelace\/home$/,
action: (page) => setLovelaceEditMode(page, true),
},
{
name: "keeps the lovelace panel when removing the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/#\/lovelace\/home$/,
action: async (page) => {
await setLovelaceEditMode(page, true);
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
timeout: SHELL_TIMEOUT,
});
await setLovelaceEditMode(page, false);
},
},
];
interface E2ELovelaceRoot extends HTMLElement {
lovelace?: {
setEditMode: (editMode: boolean) => void;
};
}
// ---------------------------------------------------------------------------
// 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));
}
async function setLovelaceEditMode(page: Page, editMode: boolean) {
await page
.locator("hui-root")
.first()
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
await page
.locator("hui-root")
.first()
.evaluate(async (el: Element, value) => {
const root = el as E2ELovelaceRoot;
const start = performance.now();
await new Promise<void>((resolve, reject) => {
const check = () => {
if (root.lovelace?.setEditMode) {
resolve();
return;
}
if (performance.now() - start > 2000) {
reject(new Error("Lovelace edit mode action was not available"));
return;
}
requestAnimationFrame(check);
};
check();
});
root.lovelace!.setEditMode(value);
}, editMode);
}
import { test, expect } from "@playwright/test";
import {
appSidebar,
appSidebarConfig,
appSidebarPanel,
assertElementContent,
defineLinkSmokeTests,
defineRouteSmokeTests,
ensureAppSidebarPanelVisible,
goToPanel,
} from "./app/src/helpers";
import {
expectNoPageErrors,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "./helpers";
import {
appRouteSmokeGroups,
configLinks,
moreInfoViewElements,
} from "./app/src/smoke";
// ---------------------------------------------------------------------------
// App shell
@@ -169,60 +34,40 @@ async function setLovelaceEditMode(page: Page, editMode: boolean) {
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));
const errors = trackPageErrors(page);
await goToPanel(page, "/");
await expect(page.locator("ha-test")).toBeAttached();
expect(errors).toHaveLength(0);
await expect(page.locator("ha-test")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
expectNoPageErrors(errors, undefined, []);
});
test("sidebar renders with expected panels", async ({ page }) => {
await goToPanel(page, "/lovelace");
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
for (const urlPath of ["lovelace", "map", "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();
await Promise.all([
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root.
...["lovelace", "map", "energy", "history"].map((urlPath) =>
expect(appSidebarPanel(page, urlPath)).toBeAttached({
timeout: QUICK_TIMEOUT,
})
),
// Config has its own special element with id="sidebar-config".
expect(appSidebarConfig(page)).toBeAttached({
timeout: QUICK_TIMEOUT,
}),
]);
});
test("sidebar navigation changes the active panel", async ({ page }) => {
await goToPanel(page, "/lovelace");
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
const historyLink = sidebar.locator("#sidebar-panel-history");
if (!(await historyLink.isVisible().catch(() => false))) {
await page.locator("ha-test >> home-assistant-main").evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-toggle-menu", {
detail: { open: true },
bubbles: true,
composed: true,
})
);
});
}
await expect(historyLink).toBeVisible({ timeout: SHELL_TIMEOUT });
const historyLink = await ensureAppSidebarPanelVisible(page, "history");
await historyLink.click();
await expect(page).toHaveURL(/\/#\/history$/, { timeout: SHELL_TIMEOUT });
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_TIMEOUT });
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
@@ -231,34 +76,29 @@ test.describe("App shell", () => {
test("sidebar renders notification badge", async ({ page }) => {
await goToPanel(page, "/lovelace");
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
const sidebar = appSidebar(page);
await expect(sidebar).toBeAttached({ timeout: QUICK_TIMEOUT });
const notificationsLink = sidebar.locator("#sidebar-notifications");
await expect(notificationsLink).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(notificationsLink).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(notificationsLink.locator(".badge").first()).toHaveText("1", {
timeout: SHELL_TIMEOUT,
timeout: QUICK_TIMEOUT,
});
});
test("sidebar marks the active panel as selected", async ({ page }) => {
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
const lovelaceLink = sidebar.locator("#sidebar-panel-lovelace");
const historyLink = sidebar.locator("#sidebar-panel-history");
const lovelaceLink = appSidebarPanel(page, "lovelace");
const historyLink = appSidebarPanel(page, "history");
await goToPanel(page, "/lovelace");
await expect(lovelaceLink).toHaveClass(/selected/, {
timeout: SHELL_TIMEOUT,
timeout: QUICK_TIMEOUT,
});
await expect(historyLink).not.toHaveClass(/selected/);
await goToPanel(page, "/history");
await expect(historyLink).toHaveClass(/selected/, {
timeout: SHELL_TIMEOUT,
timeout: QUICK_TIMEOUT,
});
await expect(lovelaceLink).not.toHaveClass(/selected/);
});
@@ -271,124 +111,16 @@ test.describe("App shell", () => {
await goToPanel(page, "/?scenario=non-admin#/lovelace");
// Wait for the sidebar to mount before asserting on its contents.
await expect(
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
// Config panel is adminOnly — should not appear for non-admin.
const configLink = page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
);
await expect(configLink).not.toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Panel navigation
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
for (const [path, element] of e2ePanelRouteAssertions) {
test(`renders registered panel ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
test.describe("Panel URL normalization", () => {
for (const {
name,
path,
element,
url,
action,
} of URL_NORMALIZATION_ASSERTIONS) {
test(name, async ({ page }) => {
await goToPanel(page, path);
await action?.(page);
await expect(page).toHaveURL(url, { timeout: SHELL_TIMEOUT });
await expect(page.locator(element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
// ---------------------------------------------------------------------------
// Tools panel (formerly Developer tools)
// ---------------------------------------------------------------------------
/**
* Every tool sub-page reachable under /config/tools, mapped to the custom
* element tools-router mounts for it (see tools-router.ts). Asserting on the
* specific element proves the route actually rendered its tool, not just the
* shared ha-panel-tools shell.
*/
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
{ route: "yaml", element: "tools-yaml-config" },
{ route: "state", element: "tools-state" },
{ route: "action", element: "tools-action" },
{ route: "template", element: "tools-template" },
{ route: "event", element: "tools-event" },
{ route: "statistics", element: "tools-statistics" },
{ route: "assist", element: "tools-assist" },
{ route: "debug", element: "tools-debug" },
];
test.describe("Tools panel", () => {
test("base path renders the tools panel", async ({ page }) => {
await goToPanel(page, "/config/tools");
await expect(page.locator("ha-panel-tools")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
for (const { route, element } of TOOLS_SUBPAGES) {
test(`renders the ${route} sub-page`, async ({ page }) => {
await goToPanel(page, `/config/tools/${route}`);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
test("service is an alias for the action tool", async ({ page }) => {
await goToPanel(page, "/config/tools/service");
await expect(page.locator("tools-action")).toBeAttached({
timeout: PANEL_TIMEOUT,
await expect(appSidebarConfig(page)).not.toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
});
// ---------------------------------------------------------------------------
// Tools redirects (old developer-tools URLs)
// ---------------------------------------------------------------------------
test.describe("Tools redirects", () => {
// The panel moved from top-level /developer-tools (pre-2026.2) to
// /config/developer-tools (2026.2), then was renamed to /config/tools
// (2026.8). Both old locations must redirect to the new one, and deep links
// must keep their sub-page. See the updateRoute() redirect in
// src/layouts/home-assistant.ts.
for (const oldBase of ["/developer-tools", "/config/developer-tools"]) {
test(`redirects ${oldBase} to the tools panel`, async ({ page }) => {
await goToPanel(page, oldBase);
await expect(page.locator("ha-panel-tools")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test(`redirects ${oldBase}/state to the state tool`, async ({ page }) => {
await goToPanel(page, `${oldBase}/state`);
await expect(page.locator("tools-state")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
defineRouteSmokeTests(appRouteSmokeGroups);
// ---------------------------------------------------------------------------
// Lovelace
@@ -417,7 +149,7 @@ test.describe("Lovelace dashboard", () => {
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
for (const { view, element, content } of moreInfoViewElements) {
test(`opens more-info ${view} view for a light entity`, async ({
page,
}) => {
@@ -455,16 +187,7 @@ test.describe("Light more-info dialog", () => {
// Each view should render its own characteristic content, not just an
// empty shell.
for (const { selector, text } of content) {
const locator = dialog.locator(selector).first();
if (text) {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT });
} else {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
}
}
await assertElementContent(dialog, content);
});
}
});
@@ -483,7 +206,6 @@ test.describe("Theming", () => {
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
undefined,
{ timeout: QUICK_TIMEOUT }
);
});
@@ -500,7 +222,6 @@ test.describe("Theming", () => {
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim() !== "",
undefined,
{ timeout: QUICK_TIMEOUT }
);
@@ -521,144 +242,27 @@ test.describe("Theming", () => {
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
const DASHBOARD_LINKS = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
const CONFIG_ROUTES: { path: string; element: string }[] = [
{ path: "/config/integrations", element: "ha-config-integrations" },
{ path: "/config/devices", element: "ha-config-devices" },
{ path: "/config/entities", element: "ha-config-entities" },
{ path: "/config/helpers", element: "ha-config-helpers" },
{ path: "/config/areas", element: "ha-config-areas" },
{ path: "/config/apps", element: "ha-config-apps" },
{ path: "/config/app", element: "ha-config-app-dashboard" },
{ path: "/config/automation", element: "ha-config-automation" },
{ path: "/config/backup", element: "ha-config-backup" },
{ path: "/config/scene", element: "ha-config-scene" },
{ path: "/config/script", element: "ha-config-script" },
{ path: "/config/blueprint", element: "ha-config-blueprint" },
{ path: "/config/cloud", element: "ha-config-cloud" },
{ path: "/config/energy", element: "ha-config-energy" },
{ path: "/config/hardware", element: "ha-config-hardware" },
{ path: "/config/labs", element: "ha-config-labs" },
{ path: "/config/lovelace", element: "ha-config-lovelace" },
{ path: "/config/person", element: "ha-config-person" },
{ path: "/config/storage", element: "ha-config-section-storage" },
{ path: "/config/tags", element: "ha-config-tags" },
{ path: "/config/users", element: "ha-config-users" },
{ path: "/config/voice-assistants", element: "ha-config-voice-assistants" },
{ path: "/config/system", element: "ha-config-system-navigation" },
{ path: "/config/info", element: "ha-config-info" },
{ path: "/config/logs", element: "ha-config-logs" },
{ path: "/config/general", element: "ha-config-section-general" },
{ path: "/config/updates", element: "ha-config-section-updates" },
{ path: "/config/repairs", element: "ha-config-repairs-dashboard" },
{ path: "/config/analytics", element: "ha-config-section-analytics" },
{ path: "/config/ai-tasks", element: "ha-config-section-ai-tasks" },
{ path: "/config/labels", element: "ha-config-labels" },
{ path: "/config/zone", element: "ha-config-zone" },
{ path: "/config/network", element: "ha-config-section-network" },
{
path: "/config/application_credentials",
element: "ha-config-application-credentials",
},
{ path: "/config/bluetooth", element: "bluetooth-config-dashboard-router" },
{ path: "/config/dhcp", element: "dhcp-config-panel" },
{ path: "/config/infrared", element: "infrared-config-dashboard-router" },
{ path: "/config/matter", element: "matter-config-panel" },
{ path: "/config/mqtt", element: "mqtt-config-panel" },
{
path: "/config/radio-frequency",
element: "radio-frequency-config-dashboard-router",
},
{ path: "/config/ssdp", element: "ssdp-config-panel" },
{ path: "/config/thread", element: "thread-config-panel" },
{ path: "/config/zeroconf", element: "zeroconf-config-panel" },
{ path: "/config/zha", element: "zha-config-dashboard-router" },
{ path: "/config/zwave_js", element: "zwave_js-config-router" },
];
const NESTED_CONFIG_ROUTES: { path: string; element: string }[] = [
{
path: "/config/integrations/dashboard",
element: "ha-config-integrations-dashboard",
},
{
path: "/config/devices/dashboard",
element: "ha-config-devices-dashboard",
},
{ path: "/config/areas/dashboard", element: "ha-config-areas-dashboard" },
{ path: "/config/backup/settings", element: "ha-config-backup-settings" },
];
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
const errors = trackPageErrors(page);
await goToPanel(page, "/config");
await expect(
page.locator("ha-panel-config, ha-config-dashboard").first()
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
// Filter known pre-existing errors from vendor code
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
expectNoPageErrors(errors);
});
test("dashboard renders key settings links", async ({ page }) => {
const getDashboard = async (page) => {
await goToPanel(page, "/config");
const dashboard = page.locator("ha-config-dashboard");
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
await expect(dashboard).toBeAttached({ timeout: QUICK_TIMEOUT });
return dashboard;
};
for (const { href, label } of DASHBOARD_LINKS) {
const link = dashboard.getByRole("link", {
name: new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
});
// eslint-disable-next-line no-await-in-loop
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
});
for (const { path, element } of CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
for (const { path, element } of NESTED_CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
defineLinkSmokeTests(
"config links point to expected pages",
configLinks,
getDashboard
);
});
+172
View File
@@ -0,0 +1,172 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import {
defineParallelSmokeTests,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
} from "../../helpers";
const APP_MAIN_SELECTOR = "ha-test >> home-assistant-main";
const APP_SIDEBAR_SELECTOR = `${APP_MAIN_SELECTOR} >> ha-sidebar`;
// The app e2e harness is built with __DEMO__=true, which enables hash routing.
// Scenario selection uses query params at root: /?scenario=foo#/lovelace.
export async function goToPanel(page: Page, path: string) {
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await Promise.all([
page.waitForSelector("ha-test", {
state: "attached",
timeout: SHELL_TIMEOUT,
}),
page.waitForFunction(
() => "__mockHass" in window && Boolean(window.__mockHass),
undefined,
{ timeout: SHELL_TIMEOUT }
),
]);
}
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
export const appSidebarPanel = (page: Page, panel: string) =>
appSidebar(page).locator(`#sidebar-panel-${panel}`);
export const appSidebarConfig = (page: Page) =>
appSidebar(page).locator("#sidebar-config");
export async function openAppSidebar(page: Page) {
await appMain(page).evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-toggle-menu", {
detail: { open: true },
bubbles: true,
composed: true,
})
);
});
}
export async function ensureAppSidebarPanelVisible(page: Page, panel: string) {
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
const link = appSidebarPanel(page, panel);
if (!(await link.isVisible().catch(() => false))) {
await openAppSidebar(page);
}
await expect(link).toBeVisible({ timeout: QUICK_TIMEOUT });
return link;
}
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export interface LinkSmokeCase {
href: string;
label: string;
}
export async function assertLink(
root: Locator,
{ href, label }: LinkSmokeCase
) {
const link = root.getByRole("link", {
name: new RegExp(`^${escapeRegExp(label)}\\b`),
});
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
export function defineLinkSmokeTests(
name: string,
links: LinkSmokeCase[],
getRoot: (page: Page) => Promise<Locator>
) {
test(name, async ({ page }) => {
const root = await getRoot(page);
await Promise.all(
links.map((link) =>
test.step(`${link.label} links to ${link.href}`, async () => {
await assertLink(root, link);
})
)
);
});
}
export interface ElementContentAssertion {
selector: string;
text?: string;
}
export interface ViewElementSmokeCase<TView extends string = string> {
view: TView;
element: string;
content: ElementContentAssertion[];
}
export async function assertElementContent(
root: Locator,
content: ElementContentAssertion[]
) {
await Promise.all(
content.map(({ selector, text }) => {
const locator = root.locator(selector).first();
return text
? expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT })
: expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
})
);
}
export interface RouteSmokeCase {
name?: string;
path: string;
element: string;
url?: RegExp;
action?: (page: Page) => Promise<void>;
}
export interface RouteSmokeGroup {
name: string;
routes: RouteSmokeCase[];
testName?: (route: RouteSmokeCase) => string;
}
export const routeCase = (path: string, element: string): RouteSmokeCase => ({
path,
element,
});
export const routeCases = (routes: [string, string][]): RouteSmokeCase[] =>
routes.map(([path, element]) => routeCase(path, element));
export const rendersRoute = (route: RouteSmokeCase) => `renders ${route.path}`;
async function assertRouteSmoke(page: Page, route: RouteSmokeCase) {
await goToPanel(page, route.path);
await route.action?.(page);
if (route.url) {
await expect(page).toHaveURL(route.url, { timeout: QUICK_TIMEOUT });
}
await expect(page.locator(route.element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
}
export function defineRouteSmokeTests(groups: RouteSmokeGroup[]) {
defineParallelSmokeTests({
groups,
groupName: (group) => group.name,
cases: (group) => group.routes,
testName: (route, group) =>
route.name ?? group.testName?.(route) ?? rendersRoute(route),
run: async ({ page, smokeCase }) => {
await assertRouteSmoke(page, smokeCase);
},
});
}
+273
View File
@@ -0,0 +1,273 @@
import { expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../../../src/dialogs/more-info/const";
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "../../helpers";
import {
rendersRoute,
routeCase,
routeCases,
type LinkSmokeCase,
type RouteSmokeCase,
type RouteSmokeGroup,
type ViewElementSmokeCase,
} from "./helpers";
import { e2ePanelRouteAssertions } from "./ha-test-panels";
// ── Config dashboard links ───────────────────────────────────────────────────
export const configLinks: LinkSmokeCase[] = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
// ── More-info dialog views ───────────────────────────────────────────────────
export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
{
view: "info",
element: "ha-more-info-info",
content: [
{ selector: "more-info-light" },
{ selector: "span.title", text: "Test Light" },
],
},
{
view: "history",
element: "ha-more-info-history-and-logbook",
// The demo loads the history component but not logbook.
content: [{ selector: "ha-more-info-history" }],
},
{
view: "settings",
element: "ha-more-info-settings",
// The scenario mocks config/entity_registry/get, so the real registry
// panel renders instead of the "no unique ID" warning.
content: [{ selector: "entity-registry-settings" }],
},
{
view: "related",
element: "ha-related-items",
// search/related is mocked to return no relations, so the empty list
// renders.
content: [{ selector: "ha-related-items >> ha-list" }],
},
{
view: "add_to",
element: "ha-more-info-add-to",
// Admin users get the default add-to action list.
content: [{ selector: "ha-add-to-action-list" }],
},
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
// ── Route smoke tests ────────────────────────────────────────────────────────
interface E2ELovelaceRoot extends HTMLElement {
lovelace?: {
setEditMode: (editMode: boolean) => void;
};
}
async function setLovelaceEditMode(page: Page, editMode: boolean) {
await page
.locator("hui-root")
.first()
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
await page
.locator("hui-root")
.first()
.evaluate(async (el: Element, value) => {
const root = el as E2ELovelaceRoot;
const start = performance.now();
await new Promise<void>((resolve, reject) => {
const check = () => {
if (root.lovelace?.setEditMode) {
resolve();
return;
}
if (performance.now() - start > 2000) {
reject(new Error("Lovelace edit mode action was not available"));
return;
}
requestAnimationFrame(check);
};
check();
});
root.lovelace!.setEditMode(value);
}, editMode);
}
const PANEL_ROUTE_ASSERTIONS = Array.from(
e2ePanelRouteAssertions,
([path, element]) => routeCase(path, element)
);
const URL_NORMALIZATION_ASSERTIONS: RouteSmokeCase[] = [
{
name: "keeps the todo panel when adding the selected entity query",
path: "/todo",
element: "ha-panel-todo",
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
},
{
name: "keeps the history panel when removing the back query",
path: "/?back=1#/history",
element: "ha-panel-history, history-panel",
url: /\/#\/history$/,
},
{
name: "keeps the logbook panel when removing the back query",
path: "/?back=1#/logbook",
element: "ha-panel-logbook",
url: /\/#\/logbook$/,
},
{
name: "keeps the lovelace panel when removing the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/#\/lovelace\/home$/,
action: async (page) => {
await setLovelaceEditMode(page, true);
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
timeout: SHELL_TIMEOUT,
});
await setLovelaceEditMode(page, false);
},
},
];
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
{ route: "yaml", element: "tools-yaml-config" },
{ route: "state", element: "tools-state" },
{ route: "action", element: "tools-action" },
{ route: "template", element: "tools-template" },
{ route: "event", element: "tools-event" },
{ route: "statistics", element: "tools-statistics" },
{ route: "assist", element: "tools-assist" },
{ route: "debug", element: "tools-debug" },
];
const TOOLS_ROUTE_ASSERTIONS = [
routeCase("/config/tools", "ha-panel-tools"),
...TOOLS_SUBPAGES.map(({ route, element }) =>
routeCase(`/config/tools/${route}`, element)
),
routeCase("/config/tools/service", "tools-action"),
];
const TOOLS_REDIRECT_ASSERTIONS = [
...["/developer-tools", "/config/developer-tools"].flatMap((oldBase) => [
routeCase(oldBase, "ha-panel-tools"),
routeCase(`${oldBase}/state`, "tools-state"),
]),
];
const CONFIG_ROUTES = routeCases([
["/config/integrations", "ha-config-integrations"],
["/config/devices", "ha-config-devices"],
["/config/entities", "ha-config-entities"],
["/config/helpers", "ha-config-helpers"],
["/config/areas", "ha-config-areas"],
["/config/apps", "ha-config-apps"],
["/config/app", "ha-config-app-dashboard"],
["/config/automation", "ha-config-automation"],
["/config/backup", "ha-config-backup"],
["/config/scene", "ha-config-scene"],
["/config/script", "ha-config-script"],
["/config/blueprint", "ha-config-blueprint"],
["/config/cloud", "ha-config-cloud"],
["/config/energy", "ha-config-energy"],
["/config/hardware", "ha-config-hardware"],
["/config/labs", "ha-config-labs"],
["/config/lovelace", "ha-config-lovelace"],
["/config/person", "ha-config-person"],
["/config/storage", "ha-config-section-storage"],
["/config/tags", "ha-config-tags"],
["/config/users", "ha-config-users"],
["/config/voice-assistants", "ha-config-voice-assistants"],
["/config/system", "ha-config-system-navigation"],
["/config/info", "ha-config-info"],
["/config/logs", "ha-config-logs"],
["/config/general", "ha-config-section-general"],
["/config/updates", "ha-config-section-updates"],
["/config/repairs", "ha-config-repairs-dashboard"],
["/config/analytics", "ha-config-section-analytics"],
["/config/ai-tasks", "ha-config-section-ai-tasks"],
["/config/labels", "ha-config-labels"],
["/config/zone", "ha-config-zone"],
["/config/network", "ha-config-section-network"],
["/config/application_credentials", "ha-config-application-credentials"],
["/config/bluetooth", "bluetooth-config-dashboard-router"],
["/config/dhcp", "dhcp-config-panel"],
["/config/infrared", "infrared-config-dashboard-router"],
["/config/matter", "matter-config-panel"],
["/config/mqtt", "mqtt-config-panel"],
["/config/radio-frequency", "radio-frequency-config-dashboard-router"],
["/config/ssdp", "ssdp-config-panel"],
["/config/thread", "thread-config-panel"],
["/config/zeroconf", "zeroconf-config-panel"],
["/config/zha", "zha-config-dashboard-router"],
["/config/zwave_js", "zwave_js-config-router"],
]);
const NESTED_CONFIG_ROUTES = routeCases([
["/config/integrations/dashboard", "ha-config-integrations-dashboard"],
["/config/devices/dashboard", "ha-config-devices-dashboard"],
["/config/areas/dashboard", "ha-config-areas-dashboard"],
["/config/backup/settings", "ha-config-backup-settings"],
]);
export const appRouteSmokeGroups: RouteSmokeGroup[] = [
{
name: "Panel navigation",
routes: PANEL_ROUTE_ASSERTIONS,
testName: (route) => `renders registered panel ${route.path}`,
},
{
name: "Panel URL normalization",
routes: URL_NORMALIZATION_ASSERTIONS,
testName: (route) => route.name!,
},
{
name: "Tools panel",
routes: TOOLS_ROUTE_ASSERTIONS,
testName: rendersRoute,
},
{
name: "Tools redirects",
routes: TOOLS_REDIRECT_ASSERTIONS,
testName: (route) => `redirects ${route.path}`,
},
{
name: "Config routes",
routes: CONFIG_ROUTES,
testName: rendersRoute,
},
{
name: "Nested config routes",
routes: NESTED_CONFIG_ROUTES,
testName: rendersRoute,
},
];
+27 -5
View File
@@ -5,6 +5,29 @@
// Usage: node test/e2e/collect-blob-reports.mjs
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
import { join, relative } from "path";
const findBlobReports = (dir) => {
const files = [];
const walk = (currentDir) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
} else if (entry.name.endsWith(".zip")) {
files.push(entryPath);
}
}
};
try {
walk(dir);
} catch {
return undefined;
}
return files;
};
const dest = "test/e2e/reports/blob";
rmSync(dest, { recursive: true, force: true });
@@ -12,10 +35,8 @@ 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 {
const files = findBlobReports(src);
if (!files?.length) {
// Suite report directory doesn't exist (e.g. job was skipped or failed
// before uploading). Skip gracefully.
process.stderr.write(
@@ -24,6 +45,7 @@ for (const suite of ["demo", "app", "gallery"]) {
continue;
}
for (const file of files) {
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
const name = relative(src, file).replace(/[\\/]/g, "-");
cpSync(file, join(dest, `${suite}-${name}`));
}
}
+29 -107
View File
@@ -1,146 +1,68 @@
import { expect, test } from "@playwright/test";
import {
expectNoPageErrors,
NAVIGATION_TIMEOUT,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
appErrors as filterAppErrors,
trackPageErrors,
} from "./helpers";
import {
activateDemoSidebarPanel,
demoCardSelector,
moreInfoCardSelector,
openDemoSidebar,
waitForDemoReady,
} from "./demo/helpers";
test.describe("Home Assistant Demo", () => {
// Collect JS errors during each test so we can assert no unexpected crashes.
let pageErrors: Error[] = [];
let pageErrors: ReturnType<typeof trackPageErrors>;
test.beforeEach(async ({ page }) => {
pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err));
pageErrors = trackPageErrors(page);
await page.goto("/");
});
function appErrors() {
return filterAppErrors(pageErrors);
}
// ── 1. Page loads ──────────────────────────────────────────────────────────
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
// The custom element is present in the document
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await waitForDemoReady(page);
// The launch screen should disappear once the app is ready
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
// No unhandled JS exceptions
expect(appErrors()).toHaveLength(0);
expectNoPageErrors(pageErrors);
});
// ── 2. Dashboard renders ───────────────────────────────────────────────────
test("dashboard renders Lovelace cards", async ({ page }) => {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
await waitForDemoReady(page);
const cardSelector = [
"hui-tile-card",
"hui-entity-card",
"hui-glance-card",
"hui-button-card",
"hui-markdown-card",
].join(", ");
await expect(page.locator(cardSelector).first()).toBeVisible({
await expect(page.locator(demoCardSelector).first()).toBeVisible({
timeout: PANEL_TIMEOUT,
});
});
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
test("sidebar navigation changes the active panel", async ({ page }) => {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
await waitForDemoReady(page);
await openDemoSidebar(page);
await activateDemoSidebarPanel(page, "map");
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
// inside a modal drawer that is closed by default. Open it first via
// the ha-menu-button in the top app-bar.
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
await expect(page.locator("ha-sidebar")).toBeVisible({
timeout: SHELL_TIMEOUT,
});
} else {
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
}
const candidatePanels = ["map", "logbook", "history", "config"];
let clicked = false;
for (const panel of candidatePanels) {
const navItem = page.locator(`#sidebar-panel-${panel}`);
// eslint-disable-next-line no-await-in-loop
const visible = await navItem.isVisible().catch(() => false);
if (visible) {
// eslint-disable-next-line no-await-in-loop
await navItem.click();
// eslint-disable-next-line no-await-in-loop
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
timeout: SHELL_TIMEOUT,
});
clicked = true;
break;
}
}
expect(clicked, "No known sidebar panel was found to click").toBe(true);
expect(appErrors()).toHaveLength(0);
expectNoPageErrors(pageErrors);
});
// ── 4. More info dialog ───────────────────────────────────────────────────
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
await waitForDemoReady(page);
// Tile cards are the most common card type in the demo; fall back to other
// clickable card types in case this platform renders a different layout.
await expect(page.locator(moreInfoCardSelector).first()).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
await page.locator(moreInfoCardSelector).first().click();
// Tile cards are the most common card type in the demo; they open the
// more-info dialog on click. Fall back to other clickable card types in
// case the demo layout on this platform doesn't include tile cards.
const cardSelector =
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
await page.locator(cardSelector).first().click();
// The more-info dialog is a top-level custom element appended to the body.
// We verify it is attached, then confirm it rendered by checking the title
// span which is slotted into the light DOM and has real layout dimensions.
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(dialog.locator("span.title")).toBeVisible({
timeout: QUICK_TIMEOUT,
});
const title = dialog.locator("span.title");
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
expect(appErrors()).toHaveLength(0);
expectNoPageErrors(pageErrors);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, type Page } from "@playwright/test";
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
export const demoCardSelector = [
"hui-tile-card",
"hui-entity-card",
"hui-glance-card",
"hui-button-card",
"hui-markdown-card",
].join(", ");
export const moreInfoCardSelector =
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
export async function waitForDemoReady(page: Page) {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
}
export async function openDemoSidebar(page: Page) {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
const modalDrawer = page.locator("ha-drawer").locator("wa-drawer");
await Promise.all([
modalDrawer.evaluate(
(element) =>
new Promise<void>((resolve) => {
element.addEventListener("wa-after-show", () => resolve(), {
once: true,
});
})
),
menuButton.click(),
]);
return;
}
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
}
export async function activateDemoSidebarPanel(page: Page, panel: string) {
const navItem = page.locator(`#sidebar-panel-${panel}`);
await expect(navItem).toBeVisible({ timeout: SHELL_TIMEOUT });
await navItem.click();
await expect(page).toHaveURL(new RegExp(`/${panel}(?:/|$)`), {
timeout: SHELL_TIMEOUT,
});
}
+61 -306
View File
@@ -7,362 +7,117 @@
* Run with:
* yarn test:e2e:gallery
*/
import { test, expect, type Page } from "@playwright/test";
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Navigate to a gallery page via hash and wait for it to render. */
async function goToGalleryPage(page: Page, hash: string) {
// First visit to let ha-gallery boot up
await page.goto(`/#${hash}`);
await page.waitForSelector("ha-gallery", { state: "attached" });
// Wait for the demo element to appear in ha-gallery's shadow root.
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
// page-description is only rendered for pages that have a description field,
// so we cannot use it as a universal readiness signal.
const demoTag = `demo-${hash.replace("/", "-")}`;
await page.waitForFunction((tag) => {
const gallery = document.querySelector("ha-gallery") as any;
return gallery?.shadowRoot?.querySelector(tag) != null;
}, demoTag);
}
/** Assert a gallery page loads without console errors.
* Demo elements live inside ha-gallery's shadow root use >> to pierce it.
*/
async function assertPageLoads(page: Page, hash: string, selector: string) {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToGalleryPage(page, hash);
// Pierce ha-gallery's shadow root with >>
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
timeout: SHELL_TIMEOUT,
});
const realErrors = errors.filter(
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
);
expect(
realErrors,
`JS errors on ${hash}: ${realErrors.join("; ")}`
).toHaveLength(0);
}
// Errors that are gallery-harness artifacts rather than bugs in the component
// under test. The Lit-context init-error family that used to live here is gone:
// ha-gallery now provides fallback contexts for every demo (mirroring the real
// app's root), so context-consuming components resolve `localize`, formatters,
// config, etc. synchronously instead of throwing during init.
const IGNORED_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
// from mock state data — pre-existing gallery data issue.
/Cannot read properties of undefined \(reading 'some'\)/,
];
// ---------------------------------------------------------------------------
// Gallery shell
// ---------------------------------------------------------------------------
import { test, expect } from "@playwright/test";
import {
expectNoPageErrors,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "./helpers";
import {
defineGallerySmokeTests,
expectGalleryDemoElement,
galleryLocator,
getGalleryDemo,
goToGalleryHome,
GALLERY_SHELL_IGNORED_PAGE_ERRORS,
} from "./gallery/helpers";
import { componentPages, lovelacePages, moreInfoPages } from "./gallery/pages";
test.describe("Gallery shell", () => {
test("page loads and ha-gallery mounts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
const errors = trackPageErrors(page);
await page.goto("/");
await expect(page.locator("ha-gallery")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
await goToGalleryHome(page);
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
expectNoPageErrors(errors, undefined, GALLERY_SHELL_IGNORED_PAGE_ERRORS);
});
test("sidebar renders navigation links", async ({ page }) => {
await page.goto("/");
await page.waitForSelector("ha-gallery", { state: "attached" });
// The gallery drawer sidebar is inside ha-gallery's shadow root
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
await goToGalleryHome(page);
await expect(galleryLocator(page, "ha-drawer")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
});
// ---------------------------------------------------------------------------
// Component pages
// ---------------------------------------------------------------------------
const componentPages: { name: string; selector: string }[] = [
{ name: "ha-alert", selector: "demo-components-ha-alert" },
{ name: "ha-badge", selector: "demo-components-ha-badge" },
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",
selector: "demo-components-ha-control-circular-slider",
},
{
name: "ha-control-number-buttons",
selector: "demo-components-ha-control-number-buttons",
},
{
name: "ha-control-select-menu",
selector: "demo-components-ha-control-select-menu",
},
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
{
name: "ha-expansion-panel",
selector: "demo-components-ha-expansion-panel",
},
{ name: "ha-faded", selector: "demo-components-ha-faded" },
{ name: "ha-form", selector: "demo-components-ha-form" },
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
{
name: "ha-hs-color-picker",
selector: "demo-components-ha-hs-color-picker",
},
{ name: "ha-input", selector: "demo-components-ha-input" },
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
{ name: "ha-list", selector: "demo-components-ha-list" },
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
{
name: "ha-progress-button",
selector: "demo-components-ha-progress-button",
},
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
{ name: "ha-selector", selector: "demo-components-ha-selector" },
{ name: "ha-slider", selector: "demo-components-ha-slider" },
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
{ name: "ha-switch", selector: "demo-components-ha-switch" },
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
{ name: "ha-tip", selector: "demo-components-ha-tip" },
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
{
name: "ha-adaptive-dialog",
selector: "demo-components-ha-adaptive-dialog",
},
{
name: "ha-adaptive-popover",
selector: "demo-components-ha-adaptive-popover",
},
];
test.describe("Components", () => {
for (const { name, selector } of componentPages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `components/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// More-info pages
// ---------------------------------------------------------------------------
const moreInfoPages: { name: string; selector: string }[] = [
{ name: "light", selector: "demo-more-info-light" },
{ name: "climate", selector: "demo-more-info-climate" },
{ name: "cover", selector: "demo-more-info-cover" },
{ name: "fan", selector: "demo-more-info-fan" },
{ name: "humidifier", selector: "demo-more-info-humidifier" },
{ name: "input-number", selector: "demo-more-info-input-number" },
{ name: "input-text", selector: "demo-more-info-input-text" },
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
{ name: "lock", selector: "demo-more-info-lock" },
{ name: "media-player", selector: "demo-more-info-media-player" },
{ name: "number", selector: "demo-more-info-number" },
{ name: "scene", selector: "demo-more-info-scene" },
{ name: "timer", selector: "demo-more-info-timer" },
{ name: "update", selector: "demo-more-info-update" },
{ name: "vacuum", selector: "demo-more-info-vacuum" },
{ name: "water-heater", selector: "demo-more-info-water-heater" },
];
test.describe("More-info dialogs", () => {
for (const { name, selector } of moreInfoPages) {
test(`more-info ${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `more-info/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Lovelace card pages
// ---------------------------------------------------------------------------
const lovelacePages: { name: string; selector: string }[] = [
{ name: "area-card", selector: "demo-lovelace-area-card" },
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
{
name: "grid-and-stack-card",
selector: "demo-lovelace-grid-and-stack-card",
},
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
{ name: "light-card", selector: "demo-lovelace-light-card" },
{ name: "map-card", selector: "demo-lovelace-map-card" },
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
{
name: "picture-elements-card",
selector: "demo-lovelace-picture-elements-card",
},
{
name: "picture-entity-card",
selector: "demo-lovelace-picture-entity-card",
},
{
name: "picture-glance-card",
selector: "demo-lovelace-picture-glance-card",
},
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
];
test.describe("Lovelace cards", () => {
for (const { name, selector } of lovelacePages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `lovelace/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Specific interaction tests
// ---------------------------------------------------------------------------
defineGallerySmokeTests("Components", "components", componentPages);
defineGallerySmokeTests("More-info dialogs", "more-info", moreInfoPages);
defineGallerySmokeTests("Lovelace cards", "lovelace", lovelacePages);
test.describe("Component interactions", () => {
test("ha-alert renders all four types", async ({ page }) => {
await goToGalleryPage(page, "components/ha-alert");
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
const demo = await getGalleryDemo(page, "components/ha-alert");
// The demo uses property binding (.alertType) not attribute binding,
// so we verify that multiple ha-alert elements are present.
// The demo uses property binding (.alertType) not attribute binding, so we
// verify that multiple ha-alert elements are present.
const alerts = demo.locator("ha-alert");
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
// There should be at least 4 alerts (one per type)
await expect(alerts)
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
.catch(async () => {
// If not exactly 4, just verify there are some (demo may include more)
const count = await alerts.count();
expect(count).toBeGreaterThanOrEqual(4);
});
await expect(alerts.nth(3)).toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("ha-button renders primary action button", async ({ page }) => {
await goToGalleryPage(page, "components/ha-button");
const demo = page.locator("ha-gallery >> demo-components-ha-button");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
const demo = await getGalleryDemo(page, "components/ha-button");
await expectGalleryDemoElement(demo, "ha-button, mwc-button");
});
test("ha-control-slider can be found in DOM", async ({ page }) => {
await goToGalleryPage(page, "components/ha-control-slider");
const demo = page.locator(
"ha-gallery >> demo-components-ha-control-slider"
);
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
const demo = await getGalleryDemo(page, "components/ha-control-slider");
await expectGalleryDemoElement(demo, "ha-control-slider");
});
test("ha-form renders schema-driven fields", async ({ page }) => {
await goToGalleryPage(page, "components/ha-form");
const demo = page.locator("ha-gallery >> demo-components-ha-form");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-form").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
const demo = await getGalleryDemo(page, "components/ha-form");
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
await goToGalleryPage(page, "components/ha-dialog");
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expectGalleryDemoElement(demo, "ha-form");
});
test("tile-card renders entity state", async ({ page }) => {
await goToGalleryPage(page, "lovelace/tile-card");
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
const demo = await getGalleryDemo(page, "lovelace/tile-card");
await expectGalleryDemoElement(demo, "hui-tile-card");
});
test("more-info light renders controls", async ({ page }) => {
await goToGalleryPage(page, "more-info/light");
const demo = page.locator("ha-gallery >> demo-more-info-light");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// Light more-info should contain a brightness or color-temp control
await expect(
demo
.locator("ha-control-slider, ha-more-info-light, more-info-content")
.first()
).toBeAttached({ timeout: SHELL_TIMEOUT });
});
const demo = await getGalleryDemo(page, "more-info/light");
test("more-info cover renders position controls", async ({ page }) => {
await goToGalleryPage(page, "more-info/cover");
const demo = page.locator("ha-gallery >> demo-more-info-cover");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expectGalleryDemoElement(
demo,
"ha-control-slider, ha-more-info-light, more-info-content",
SHELL_TIMEOUT
);
});
test("ha-gauge renders a gauge element", async ({ page }) => {
await goToGalleryPage(page, "components/ha-gauge");
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// ha-gauge page is markdown-based; gauge elements render in the description area
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
await getGalleryDemo(page, "components/ha-gauge");
// ha-gauge page is markdown-based; gauge elements render in the description area.
await expect(galleryLocator(page, "ha-gauge").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-switch toggles state on click", async ({ page }) => {
await goToGalleryPage(page, "components/ha-switch");
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
const demo = await getGalleryDemo(page, "components/ha-switch");
// Find the first interactive (non-disabled) switch. Pull its checked state
// from the property ha-switch toggles via property, not the attribute.
// from the property because ha-switch toggles via property, not attribute.
const switchEl = demo.locator("ha-switch:not([disabled])").first();
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
const before = await switchEl.evaluate((el: any) => el.checked === true);
const before = await switchEl.evaluate(
(el: HTMLElement & { checked?: boolean }) => el.checked === true
);
await switchEl.click();
await expect
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
timeout: QUICK_TIMEOUT,
})
.poll(
() =>
switchEl.evaluate(
(el: HTMLElement & { checked?: boolean }) => el.checked === true
),
{ timeout: QUICK_TIMEOUT }
)
.toBe(!before);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { expect, type Locator, type Page } from "@playwright/test";
import {
defineParallelSmokeTests,
expectNoPageErrors,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "../helpers";
export const GALLERY_SHELL_IGNORED_PAGE_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
];
export const GALLERY_IGNORED_PAGE_ERRORS: RegExp[] = [
...GALLERY_SHELL_IGNORED_PAGE_ERRORS,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
// from mock state data - pre-existing gallery data issue.
/Cannot read properties of undefined \(reading 'some'\)/,
];
export interface GalleryPageSmokeCase {
name: string;
selector: string;
}
export const galleryLocator = (page: Page, selector: string) =>
page.locator(`ha-gallery >> ${selector}`);
const galleryDemoTag = (hash: string) => `demo-${hash.replace(/\//g, "-")}`;
async function waitForGalleryReady(page: Page) {
await expect(page.locator("ha-gallery")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
}
export async function goToGalleryHome(page: Page) {
await page.goto("/");
await waitForGalleryReady(page);
}
export async function goToGalleryPage(page: Page, hash: string) {
await page.goto(`/#${hash}`);
}
async function expectGalleryPageSelector(page: Page, selector: string) {
const locator = galleryLocator(page, selector).first();
await expect(locator).toBeAttached({ timeout: SHELL_TIMEOUT });
return locator;
}
export async function getGalleryDemo(page: Page, hash: string) {
await goToGalleryPage(page, hash);
return expectGalleryPageSelector(page, galleryDemoTag(hash));
}
export async function assertGalleryPageLoads(
page: Page,
hash: string,
selector: string
) {
const errors = trackPageErrors(page);
await goToGalleryPage(page, hash);
await expectGalleryPageSelector(page, selector);
expectNoPageErrors(errors, hash, GALLERY_IGNORED_PAGE_ERRORS);
}
export function defineGallerySmokeTests(
groupName: string,
routePrefix: string,
pages: GalleryPageSmokeCase[]
) {
defineParallelSmokeTests({
groups: [{ name: groupName, routePrefix, pages }],
groupName: (group) => group.name,
cases: (group) => group.pages,
testName: (smokeCase) => `${smokeCase.name} renders without errors`,
run: async ({ page, group, smokeCase }) => {
await assertGalleryPageLoads(
page,
`${group.routePrefix}/${smokeCase.name}`,
smokeCase.selector
);
},
});
}
export async function expectGalleryDemoElement(
demo: Locator,
selector: string,
timeout = QUICK_TIMEOUT
) {
await expect(demo.locator(selector).first()).toBeAttached({ timeout });
}
+117
View File
@@ -0,0 +1,117 @@
import type { GalleryPageSmokeCase } from "./helpers";
export const componentPages: GalleryPageSmokeCase[] = [
{ 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",
},
];
export const moreInfoPages: GalleryPageSmokeCase[] = [
{ 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" },
];
export const lovelacePages: GalleryPageSmokeCase[] = [
{ 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" },
];
+64 -17
View File
@@ -1,6 +1,7 @@
/**
* Shared helpers and constants for Playwright e2e suites.
*/
import { expect, test, type Page } from "@playwright/test";
// ── Timeouts ────────────────────────────────────────────────────────────────
// Centralised so tweaks don't require search-and-replace across spec files.
@@ -17,21 +18,67 @@ export const NAVIGATION_TIMEOUT = 30_000;
// ── Error filtering ─────────────────────────────────────────────────────────
/**
* Filter out errors known to be unrelated to the app under test:
* - ResizeObserver loop notifications (browser quirk, harmless)
* - Non-Error rejections (mock data throws plain objects)
* - Browser extension noise
*/
export function appErrors(errors: { message: string }[] | string[]) {
const messages =
typeof errors[0] === "string"
? (errors as string[])
: (errors as { message: string }[]).map((e) => e.message);
return messages.filter(
(msg) =>
!msg.includes("ResizeObserver") &&
!msg.includes("Non-Error") &&
!msg.includes("Extension context")
);
type PageError = { message: string } | string;
export const IGNORED_PAGE_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
];
export function trackPageErrors(page: Page) {
const errors: PageError[] = [];
page.on("pageerror", (error) => errors.push(error));
return errors;
}
function pageErrors(errors: PageError[], ignoredErrors = IGNORED_PAGE_ERRORS) {
return errors
.map((error) => (typeof error === "string" ? error : error.message))
.filter((message) =>
ignoredErrors.every((pattern) => !pattern.test(message))
);
}
export function expectNoPageErrors(
errors: PageError[],
context?: string,
ignoredErrors = IGNORED_PAGE_ERRORS
) {
const realErrors = pageErrors(errors, ignoredErrors);
const details = realErrors.length ? `: ${realErrors.join("; ")}` : "";
expect(
realErrors,
context ? `JS errors on ${context}${details}` : `JS errors${details}`
).toHaveLength(0);
}
export interface DefineParallelSmokeTestsOptions<TGroup, TCase> {
groups: readonly TGroup[];
groupName: (group: TGroup) => string;
cases: (group: TGroup) => readonly TCase[];
testName: (smokeCase: TCase, group: TGroup) => string;
run: (context: {
page: Page;
group: TGroup;
smokeCase: TCase;
}) => Promise<void>;
}
export function defineParallelSmokeTests<TGroup, TCase>({
groups,
groupName,
cases,
testName,
run,
}: DefineParallelSmokeTestsOptions<TGroup, TCase>) {
for (const group of groups) {
test.describe(groupName(group), () => {
for (const smokeCase of cases(group)) {
test(testName(smokeCase, group), async ({ page }) => {
await run({ page, group, smokeCase });
});
}
});
}
}
+21
View File
@@ -0,0 +1,21 @@
const DEFAULT_LOCAL_WORKERS = "60%";
const VALID_WORKERS = /^[1-9]\d*%?$/;
export const getE2EWorkers = (): number | string => {
if (process.env.CI) {
return 1;
}
const workers = process.env.E2E_WORKERS;
if (!workers) {
return DEFAULT_LOCAL_WORKERS;
}
if (!VALID_WORKERS.test(workers)) {
throw new Error(
`E2E_WORKERS must be a positive integer or percentage, received "${workers}".`
);
}
return workers.endsWith("%") ? workers : Number(workers);
};
+4 -1
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
const APP_PORT = 8095;
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
@@ -11,8 +12,10 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results",
outputDir: "test-results/app",
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
use: {
+4 -1
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
// 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`
@@ -16,8 +17,10 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results",
outputDir: "test-results/demo",
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
use: {
+4 -1
View File
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
const GALLERY_PORT = 8100;
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
@@ -11,8 +12,10 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results",
outputDir: "test-results/gallery",
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
use: {
+156 -26
View File
@@ -1,16 +1,106 @@
#!/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.
// then collects and merges blob reports locally 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".
//
// Using ; or running suites independently avoids the && short-circuit problem
// where a failing suite skips the remaining suites and their blob reports.
// Running suites independently avoids the && short-circuit problem where a
// failing suite skips the remaining suites and their blob reports.
// Set E2E_WORKERS to a number or percentage to override local workers.
// Cold local builds run sequentially because suites share generated assets.
import { execFileSync } from "child_process";
import { execFileSync, spawn } from "child_process";
const TRUE_VALUES = new Set(["1", "true", "yes"]);
const SUITE_SERVERS = {
demo: { port: 8090, suite: "demo" },
app: { port: 8095, suite: "e2e-app" },
gallery: { port: 8100, suite: "gallery" },
};
const isTruthy = (value) => TRUE_VALUES.has(value?.toLowerCase() ?? "");
const hasManagedServer = async (suite) => {
const server = SUITE_SERVERS[suite];
if (!server) return false;
try {
const response = await fetch(
`http://localhost:${server.port}/__ha_dev_status`,
{ signal: AbortSignal.timeout(1000) }
);
if (!response.ok) return false;
const status = await response.json();
return status.server === "ha-frontend-dev" && status.suite === server.suite;
} catch {
return false;
}
};
const formatDuration = (ms) => {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`;
};
const writePrefixed = (suite, stream, chunk, pending) => {
const lines = `${pending.value}${chunk}`.split(/\r?\n/);
pending.value = lines.pop() ?? "";
for (const line of lines) {
stream.write(`[${suite}] ${line}\n`);
}
};
const flushPrefixed = (suite, stream, pending) => {
if (!pending.value) return;
stream.write(`[${suite}] ${pending.value}\n`);
pending.value = "";
};
const runSuite = (suite, env) =>
new Promise((resolve) => {
const started = Date.now();
const child = spawn("yarn", [`test:e2e:${suite}`], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
const pendingStdout = { value: "" };
const pendingStderr = { value: "" };
const workerLabel = env.E2E_WORKERS ? ` (workers: ${env.E2E_WORKERS})` : "";
process.stdout.write(
`\n--- Running suite: test:e2e:${suite}${workerLabel} ---\n`
);
child.stdout.on("data", (chunk) =>
writePrefixed(suite, process.stdout, chunk, pendingStdout)
);
child.stderr.on("data", (chunk) =>
writePrefixed(suite, process.stderr, chunk, pendingStderr)
);
child.on("error", (err) => {
flushPrefixed(suite, process.stdout, pendingStdout);
flushPrefixed(suite, process.stderr, pendingStderr);
process.stderr.write(`[${suite}] Failed to start: ${err.message}\n`);
resolve({ suite, code: 1, duration: Date.now() - started });
});
child.on("close", (code) => {
flushPrefixed(suite, process.stdout, pendingStdout);
flushPrefixed(suite, process.stderr, pendingStderr);
const duration = Date.now() - started;
process.stdout.write(
`--- Finished suite: test:e2e:${suite} (${formatDuration(duration)}) ---\n`
);
resolve({ suite, code: code ?? 1, duration });
});
});
const suites = process.argv.slice(2);
if (!suites.length) {
@@ -18,32 +108,72 @@ if (!suites.length) {
process.exit(1);
}
const failures = [];
const hasAllManagedServers = (
await Promise.all(suites.map(hasManagedServer))
).every(Boolean);
const sequential =
isTruthy(process.env.E2E_SEQUENTIAL) ||
(suites.length > 1 && !hasAllManagedServers);
const skipMerge = isTruthy(process.env.E2E_SKIP_MERGE);
const suiteWorkers =
!sequential &&
!process.env.CI &&
!process.env.E2E_WORKERS &&
suites.length > 1
? `${Math.max(1, Math.floor(60 / suites.length))}%`
: undefined;
const suiteEnv = suiteWorkers
? { ...process.env, E2E_WORKERS: suiteWorkers }
: process.env;
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);
const results = [];
if (sequential) {
if (!isTruthy(process.env.E2E_SEQUENTIAL)) {
process.stdout.write(
"Running suites sequentially because not all managed dev servers are available.\n"
);
}
for (const suite of suites) {
// eslint-disable-next-line no-await-in-loop
results.push(await runSuite(suite, suiteEnv));
}
} else {
results.push(
...(await Promise.all(suites.map((suite) => runSuite(suite, suiteEnv))))
);
}
const failures = results
.filter(({ code }) => code !== 0)
.map(({ suite }) => suite);
process.stdout.write("\nE2E suite timings:\n");
for (const { suite, duration } of results) {
process.stdout.write(`- test:e2e:${suite}: ${formatDuration(duration)}\n`);
}
// Collect and merge blob reports regardless of suite outcomes.
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
if (skipMerge) {
process.stdout.write(
"\nSkipping merged e2e report because E2E_SKIP_MERGE is set.\n"
);
} else {
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(
+9 -6
View File
@@ -1,19 +1,20 @@
import { assert, describe, it } from "vitest";
import { createHassioSession } from "../../src/data/hassio/ingress";
import type { HomeAssistant } from "../../src/types";
describe("Create hassio session", () => {
const hass = {
callWS: async () => ({
session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh",
config: { version: "1.0.0" },
callApi: async () => ({
data: { session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh" },
}),
} as unknown as Pick<HomeAssistant, "callWS">;
};
it("Test create session without HTTPS", async () => {
// @ts-ignore
global.document = {};
// @ts-ignore
global.location = {};
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -26,6 +27,7 @@ describe("Create hassio session", () => {
global.document = {};
// @ts-ignore
global.location = { protocol: "https:" };
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -41,8 +43,9 @@ describe("Create hassio session", () => {
});
it("Test fail to create", async () => {
const createSessionPromise = createHassioSession({
callWS: async () => {
throw new Error("Failed to create session");
// @ts-ignore
callApi: async () => {
// noop
},
}).then(
() => true,
@@ -1,261 +0,0 @@
import { assert, describe, it } from "vitest";
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
import {
formatClockCardDate,
getClockCardDateConfig,
getClockCardDateTimeFormatOptions,
hasClockCardDate,
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
describe("clock-date-format", () => {
const date = new Date("2024-11-08T10:20:30.000Z");
it("returns an empty config when date format is missing", () => {
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
});
it("preserves literal token order", () => {
const config = getClockCardDateConfig({
date_format: [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
],
});
assert.deepEqual(config.parts, [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
]);
});
it("filters invalid date tokens", () => {
const config = getClockCardDateConfig({
date_format: [
"month-short",
"invalid",
"year-2-digit",
] as unknown as ClockCardDatePart[],
});
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
});
it("builds Intl options from selected date tokens", () => {
const options = getClockCardDateTimeFormatOptions({
parts: [
"weekday-short",
"separator-slash",
"day-2-digit",
"month-long",
"month-numeric",
"year-2-digit",
],
});
assert.deepEqual(options, {
weekday: "short",
day: "2-digit",
month: "numeric",
year: "2-digit",
});
});
it("reports whether any date part is configured", () => {
assert.equal(hasClockCardDate(), false);
assert.equal(hasClockCardDate({ date_format: [] }), false);
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
assert.equal(
hasClockCardDate({ date_format: ["separator-new-line"] }),
true
);
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
});
it("formats output in configured part order with literal separators", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-dash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08-24");
});
it("uses separator only for the next gap", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dot",
"month-numeric",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8.11 2024");
});
it("supports using the same separator style multiple times", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-slash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08/24");
});
it("renders separators even when no value follows", () => {
const result = formatClockCardDate(
date,
{
parts: ["day-numeric", "separator-dash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "8-.");
});
it("renders separators even when no value precedes", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-slash", "separator-dot", "day-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "/.8");
});
it("renders all consecutive separators between values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8-/.11");
});
it("renders repeated separators without deduplication", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-dash",
"separator-dash",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8---11");
});
it("renders separator-only configurations", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-dash", "separator-slash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "-/.");
});
it("supports inserting a new line between date values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-new-line",
"day-2-digit",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "11\n08 2024");
});
it("allows multiple variants for the same date part", () => {
const result = formatClockCardDate(
date,
{
parts: ["month-short", "month-long", "year-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "Nov November 2024");
});
it("filters invalid tokens when formatting", () => {
const config = getClockCardDateConfig({
date_format: [
"month-numeric",
"invalid",
"year-numeric",
] as unknown as ClockCardDatePart[],
});
const result = formatClockCardDate(date, config, "en", "UTC");
assert.equal(result, "11 2024");
});
});
@@ -1,225 +0,0 @@
import { describe, expect, it } from "vitest";
import {
migrateEntitiesCardConfig,
migrateGlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/migrate-card-config";
import type {
EntitiesCardConfig,
GlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
describe("migrateEntitiesCardConfig", () => {
it("migrates the legacy `format` option to `time_format` on native rows", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
// `format` is dropped after migration
expect(result.entities[0]).not.toHaveProperty("format");
});
it("leaves custom rows untouched", () => {
const customRow = {
type: "custom:multiple-entity-row",
entity: "sensor.power",
format: "precision1",
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
// Nothing changed, so the same config reference is returned and the
// custom row keeps its own `format` semantics.
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.temperature" }, { entity: "light.kitchen" }],
} as EntitiesCardConfig;
expect(migrateEntitiesCardConfig(config)).toBe(config);
});
it("passes string rows through untouched", () => {
const config = {
type: "entities",
entities: ["sensor.temperature", { entity: "sensor.x", format: "time" }],
} as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toBe("sensor.temperature");
expect(result.entities[1]).toEqual({
entity: "sensor.x",
time_format: "time",
});
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "entities",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
state_color: true,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: false },
{
entity: "sensor.humidity",
type: "simple-entity",
state_color: true,
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
color: "state",
entities: [
"light.bed_light",
{ entity: "switch.ac", color: "none" },
{ entity: "sensor.humidity", type: "simple-entity", color: "state" },
],
});
});
it("keeps an explicit color over the legacy state_color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [{ entity: "switch.ac", state_color: true, color: "red" }],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [{ entity: "switch.ac", color: "red" }],
});
});
it("keeps state_color on custom rows untouched", () => {
const customRow = {
entity: "sensor.power",
type: "custom:multiple-entity-row",
state_color: true,
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("migrates conditional rows and their inner rows", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [
{
type: "conditional",
state_color: false,
conditions: [],
row: { entity: "light.bed_light", state_color: true },
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [
{
type: "conditional",
color: "none",
conditions: [],
row: { entity: "light.bed_light", color: "state" },
},
],
});
});
});
describe("migrateGlanceCardConfig", () => {
it("migrates the legacy `format` option to `time_format`", () => {
const config = {
type: "glance",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
expect(result.entities[0]).not.toHaveProperty("format");
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "glance",
entities: ["sensor.temperature", { entity: "light.kitchen" }],
} as GlanceCardConfig;
expect(migrateGlanceCardConfig(config)).toBe(config);
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "glance",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateGlanceCardConfig({
type: "glance",
state_color: false,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: true },
],
} as unknown as GlanceCardConfig)
).toEqual({
type: "glance",
color: "none",
entities: ["light.bed_light", { entity: "switch.ac", color: "state" }],
});
});
});
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { applyDefaultColor } from "../../../../src/panels/lovelace/common/entity-color-config";
describe("applyDefaultColor", () => {
interface TestEntityConfig {
entity: string;
type?: string;
color?: string;
}
it("applies the default color when none is set", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, "state")).toEqual({
entity: "light.a",
color: "state",
});
});
it("keeps an explicit color", () => {
const config: TestEntityConfig = { entity: "light.a", color: "red" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
it("returns the same object without a default color", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, undefined)).toBe(config);
});
it("keeps custom elements untouched", () => {
const config: TestEntityConfig = { entity: "light.a", type: "custom:foo" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
});
+255 -467
View File
File diff suppressed because it is too large Load Diff