Compare commits

..

16 Commits

Author SHA1 Message Date
Aidan Timson 89da6991a4 Centralise dependency cache preparation 2026-07-22 10:44:35 +01:00
Aidan Timson a0ac118f24 Use one dependency cache key 2026-07-22 09:16:29 +01:00
Aidan Timson e3e0cba581 Add container-compatible dependency cache 2026-07-22 09:16:29 +01:00
Aidan Timson 368803e04b Share dependencies across E2E jobs 2026-07-22 09:16:29 +01:00
Aidan Timson e47cebcf6e Workflows shared node modules cache (#53113)
* Benchmark shared dependency cache

* Avoid restoring warm producer cache

* Benchmark build with shared dependencies

* Use shared dependencies in CI

* Use one dependency cache key

* Allow manual CI runs

* Fall back when dependency cache is unavailable

* Keep dependency cache fallbacks fast
2026-07-22 10:16:08 +03:00
Petar Petrov e8bb029d7e Fix excessive y-axis decimals on near-flat energy charts (#53229) 2026-07-22 09:07:24 +02:00
Petar Petrov 4373f016c9 Fix energy usage graph stacking order for grid + solar without battery (#53230)
* Fix energy usage graph stacking so grid consumption stays above solar

* remove useless comment
2026-07-22 09:06:31 +02:00
renovate[bot] 9820428cd3 Update dependency @material/web to v2.5.0 (#53175)
* Update dependency @material/web to v2.5.0

* Update internal style imports for @material/web 2.5.0

2.5.0 renamed the generated internal `*-styles.js` modules to
`*-styles.cssresult.js` (they still export the named `styles`
CSSResult). Point our deep imports at the new paths.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-22 07:02:17 +00:00
karwosts 4fa09b0085 Don't present toggle as a ui_action option for untoggleable entities (#53210)
* Don't present toggle as a ui_action option for untoggleable entities

* Fix climate enums

* unmemoize

* fix test

* Add button/valve to canToggleDomain
2026-07-22 08:13:39 +03:00
Aidan Timson 5d63fad772 Migrate section and view editors to conditional visibility (#53217) 2026-07-22 08:11:08 +03:00
Aidan Timson 7f27c9a948 E2E performance improvements and consolidation (#53057)
* Run e2e suites in parallel

* Make suites run as parallel jobs in CI. Merge reports after

* Consolidate app route e2e helpers

* Move app e2e smoke data out of spec

* Tighten app e2e smoke timeouts

* Split e2e helpers by suite

* Consolidate app e2e smoke data

* Move app e2e helpers into src

* Enable workers and full paralelism to tests. Use 60% for local (4 in my case) and 1 for CI

* Shard e2e tests

* Fold e2e-local into report job

* Fix name

* Format

* Double sharding count

* Shard demo (2) and gallery (3)

* rm

* 6,4,6

* Add local sharded e2e runner

* Queue e2e benchmark runs

* Use test mode for e2e builds

* Fix e2e benchmark concurrency

* Build e2e app artifact modern only

* Build demo e2e artifact modern only

* 4/4/2 sharding

* Remove local sharded e2e runner

* Use Playwright image for e2e tests

* Remove redundant e2e test cases

* Run e2e sidebar checks concurrently

* Consolidate config link e2e checks

* Avoid duplicate gallery page waits

* Avoid slow gallery alert count wait

* Run app e2e readiness waits concurrently

* Avoid duplicate gallery readiness wait

* Restore concurrency

* Restore concurrency

* Share parallel e2e smoke registration

* Restore demo page error tracking

* Remove ineffective gallery test mode

* Remove unused route project filtering

* Remove duplicate smoke test parallel mode

* Keep page error filtering internal

* Require helper reuse in test changes

* Clarify local E2E server workflow

* Stabilize demo sidebar navigation test

* Avoid concurrent cold E2E builds

* Validate E2E worker overrides

* Restore real demo sidebar clicks

* Keep app shell error checks strict

* Limit gallery shell error filtering

* Address E2E review findings
2026-07-21 22:27:37 +02:00
Maarten Lakerveld c07264fc27 Add onboarding survey toast (#53221)
Conditions/triggers/actions use the same logic for the automations picker sorting
2026-07-21 21:54:39 +02:00
renovate[bot] 978ad69c1b Update dependency lint-staged to v17.1.0 (#53227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-21 21:45:41 +02:00
Paul Bottein 047ebd4524 Sync entity constants with core (#53223)
* Sync entity constants with core

* Suggest mean statistics graphs for measurement_angle sensors
2026-07-21 21:45:12 +02:00
karwosts a25aa656a3 Replace feature magic numbers in media-player-model (#53214) 2026-07-21 10:04:39 +02:00
Simon Lamon 88f244b9df Replace the memoize call to fullcalendar (#53215)
Replace the memoize one
2026-07-21 10:04:13 +02:00
89 changed files with 2470 additions and 1571 deletions
+16 -8
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,7 +41,7 @@ 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. The default is 8124, or 8123 in a devcontainer.
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
@@ -43,15 +49,17 @@ Each suite has its own dev server port. Playwright reuses an existing server loc
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` |
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
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.
@@ -0,0 +1,36 @@
name: Prepare dependencies
description: Install and cache the complete dependency tree
runs:
using: composite
steps:
- name: Check for complete dependency tree
id: dependencies
continue-on-error: true
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'
continue-on-error: true
uses: actions/cache/save@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/**') }}
+22 -1
View File
@@ -8,16 +8,37 @@ inputs:
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache before installing
default: "false"
runs:
using: composite
steps:
- name: Restore complete dependency tree
id: dependency-cache
if: inputs.node-modules-cache == 'true'
continue-on-error: true
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/**') }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Install dependencies
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
+26 -5
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -21,16 +22,30 @@ permissions:
contents: read
jobs:
lint:
name: Lint and check format
prepare-dependencies:
name: 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 and install
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
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: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
@@ -63,14 +78,17 @@ jobs:
run: yarn run lint:licenses
test:
name: Run tests
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 and install
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
@@ -80,6 +98,7 @@ jobs:
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
@@ -88,8 +107,10 @@ jobs:
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
with:
node-modules-cache: true
- name: Build Application
uses: ./.github/actions/build
with:
+218 -47
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,49 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
- name: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
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: Prepare dependencies
uses: ./.github/actions/prepare-dependencies
# ── 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 +86,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 +94,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 +117,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 +125,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 +144,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 +183,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 +319,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 +360,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 +372,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(
+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 -2
View File
@@ -77,7 +77,7 @@
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
"@material/web": "2.5.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
@@ -196,7 +196,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.8",
"lint-staged": "17.1.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
+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": [
@@ -91,6 +102,11 @@
"description": "Group formatjs monorepo package",
"groupName": "formatjs",
"matchPackageNames": ["@formatjs/**"]
},
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
}
]
}
+6
View File
@@ -6,11 +6,17 @@ export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
return false;
}
if (domain === "button" || domain === "input_button") {
return "press" in services;
}
if (domain === "lock") {
return "lock" in services;
}
if (domain === "cover") {
return "open_cover" in services;
}
if (domain === "valve") {
return "open_valve" in services;
}
return "turn_on" in services;
};
+5 -1
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { ClimateEntityFeature } from "../../data/climate";
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
@@ -26,7 +27,10 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
}
if (domain === "climate") {
return supportsFeature(stateObj, 4096);
return (
supportsFeature(stateObj, ClimateEntityFeature.TURN_ON) &&
supportsFeature(stateObj, ClimateEntityFeature.TURN_OFF)
);
}
return canToggleDomain(hass, domain);
+7 -2
View File
@@ -89,7 +89,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
device_class: [
"battery",
"battery_charging",
"co",
"carbon_monoxide",
"cold",
"connectivity",
"door",
@@ -227,7 +227,12 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"voltage",
"volume_flow_rate",
],
state_class: ["measurement", "total", "total_increasing"],
state_class: [
"measurement",
"measurement_angle",
"total",
"total_increasing",
],
},
switch: {
device_class: ["outlet", "switch"],
+23 -12
View File
@@ -1,12 +1,27 @@
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
export function computeYAxisFractionDigits(
min: number,
max: number,
// Bar axes render from 0, so union the extent with 0 to match.
includeZero = false
): number {
const lo = includeZero ? Math.min(min, 0) : min;
const hi = includeZero ? Math.max(max, 0) : max;
const range = hi - lo;
if (!Number.isFinite(range) || range <= 0) return 1;
// A near-zero range is fp noise; deriving digits from it would pad the labels
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
@@ -38,9 +53,7 @@ const resolveYAxisBound = (
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
// rendered ticks span from 0 even when the data does not. Union the extent
// with 0 to match the labels ECharts actually draws.
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
includeZero?: boolean;
onFractionDigits: (digits: number) => void;
}): {
@@ -52,13 +65,11 @@ export function createYAxisPrecisionBounds(options: {
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
let extentMin = resolvedMin ?? values.min;
let extentMax = resolvedMax ?? values.max;
if (includeZero) {
extentMin = Math.min(extentMin, 0);
extentMax = Math.max(extentMax, 0);
}
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return resolvedMin;
},
max: (values) => resolveYAxisBound(max, values),
+3 -3
View File
@@ -1,8 +1,8 @@
import { AssistChip } from "@material/web/chips/internal/assist-chip";
import { styles } from "@material/web/chips/internal/assist-styles";
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
+5 -5
View File
@@ -1,9 +1,9 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
+4 -4
View File
@@ -1,8 +1,8 @@
import { InputChip } from "@material/web/chips/internal/input-chip";
import { styles } from "@material/web/chips/internal/input-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@@ -33,7 +33,6 @@ const HIDDEN_ATTRIBUTES = [
"battery_level",
"code_arm_required",
"code_format",
"color_modes",
"device_class",
"editable",
"effect_list",
+1 -1
View File
@@ -1,5 +1,5 @@
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
import { styles } from "@material/web/list/internal/listitem/list-item-styles.cssresult.js";
import { css, html, nothing, type TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import "./ha-ripple";
+1 -1
View File
@@ -1,5 +1,5 @@
import { List } from "@material/web/list/internal/list";
import { styles } from "@material/web/list/internal/list-styles";
import { styles } from "@material/web/list/internal/list-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+2 -2
View File
@@ -1,6 +1,6 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles";
import { styles } from "@material/web/button/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+2 -2
View File
@@ -1,6 +1,6 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
+2 -2
View File
@@ -1,6 +1,6 @@
import { IconButton } from "@material/web/iconbutton/internal/icon-button";
import { styles } from "@material/web/iconbutton/internal/outlined-styles";
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles";
import { styles } from "@material/web/iconbutton/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+1 -1
View File
@@ -1,6 +1,6 @@
import { AttachableController } from "@material/web/internal/controller/attachable-controller";
import { Ripple } from "@material/web/ripple/internal/ripple";
import { styles } from "@material/web/ripple/internal/ripple-styles";
import { styles } from "@material/web/ripple/internal/ripple-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+40 -14
View File
@@ -1,19 +1,23 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
connectionContext,
entitiesContext,
} from "../../data/context";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-picker";
import "../ha-state-icon";
@customElement("ha-selector-icon")
export class HaIconSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: IconSelector;
@property() public value?: string;
@@ -30,10 +34,21 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
@state()
@consumeEntityState({ entityIdPath: ["context", "icon_entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
private _placeholderTask = new AsyncValueTask(this, {
task: ([
@@ -44,19 +59,31 @@ export class HaIconSelector extends LitElement {
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
if (
placeholder ||
attributeIcon ||
!entities ||
!config ||
!connection ||
!stateObj
) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
return entityIcon(
entities,
config.config,
connection.connection,
stateObj
);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
this._entities,
this._config,
this._connection,
stateObj,
] as const;
},
@@ -72,7 +99,6 @@ export class HaIconSelector extends LitElement {
return html`
<ha-icon-picker
.hass=${this.hass}
.label=${this.label}
.value=${this.value}
.required=${this.required}
+1 -1
View File
@@ -1,5 +1,5 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
+1 -2
View File
@@ -1,4 +1,3 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
@@ -411,7 +410,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
export const getBackupTypes = memoize((isHassio: boolean) =>
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
+2 -2
View File
@@ -127,7 +127,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
"away_mode",
"changed_by",
"code_format",
"color_modes",
"current_activity",
"device_class",
"editable",
@@ -177,6 +176,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
"source_type",
"source",
"state_class",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_mode",
@@ -190,7 +190,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"access_token",
"available_modes",
"color_modes",
"editable",
"effect_list",
"entity_picture",
@@ -207,6 +206,7 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"sound_mode_list",
"source_list",
"state_class",
"supported_color_modes",
"swing_modes",
"token",
];
+8
View File
@@ -1,6 +1,11 @@
import type { Connection } from "home-assistant-js-websocket";
import type { ShortcutItem } from "./home_shortcuts";
export interface SurveyInteraction {
date: string;
action: "opened" | "dismissed";
}
export interface CoreFrontendUserData {
showEntityIdPicker?: boolean;
default_panel?: string;
@@ -16,6 +21,9 @@ export interface CoreFrontendSystemData {
default_panel?: string;
onboarded_version?: string;
onboarded_date?: string;
surveys?: {
onboarding?: SurveyInteraction;
};
}
export interface HomeFrontendSystemData {
+2
View File
@@ -149,6 +149,7 @@ export const weatherAttrIcons = {
humidity: mdiWaterPercent,
wind_bearing: mdiWeatherWindy,
wind_speed: mdiWeatherWindy,
wind_gust_speed: mdiWeatherWindy,
pressure: mdiGauge,
temperature: mdiThermometer,
uv_index: mdiSunWireless,
@@ -268,6 +269,7 @@ export const getWeatherUnit = (
return (
stateObj.attributes.temperature_unit || config.unit_system.temperature
);
case "wind_gust_speed":
case "wind_speed":
return stateObj.attributes.wind_speed_unit || `${lengthUnit}/h`;
case "cloud_coverage":
+14
View File
@@ -20,6 +20,7 @@ import {
removeLaunchScreen,
renderLaunchScreenInfoBox,
} from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
registerServiceWorker,
supportsServiceWorker,
@@ -59,6 +60,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
private _httpPendingDialogOpen = false;
private _onboardingSurveyChecked = false;
private _panelUrl: string;
@storage({ key: "ha-version", state: false, subscribe: false })
@@ -108,6 +111,17 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkHttpPendingConfig();
}
if (
changedProps.has("hass") &&
!this._onboardingSurveyChecked &&
this.hass?.user &&
this.hass.systemData
) {
this._onboardingSurveyChecked = true;
if (!__DEMO__) {
checkOnboardingSurveyToast(this, this.hass);
}
}
}
protected update(changedProps: PropertyValues<this>) {
+5 -1
View File
@@ -15,6 +15,7 @@ export interface ShowToastParams {
message:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
action?: ToastActionParams;
dismiss?: () => void;
duration?: number;
dismissable?: boolean;
bottomOffset?: number;
@@ -71,7 +72,10 @@ class NotificationManager extends LitElement {
this._toast?.show();
}
private _toastClosed(_ev: HASSDomEvent<ToastClosedEventDetail>) {
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
if (ev.detail.reason === "dismiss") {
this._parameters?.dismiss?.();
}
this._parameters = undefined;
}
@@ -30,7 +30,6 @@ const SCHEMA = [
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
@@ -80,7 +80,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
"available_modes",
"code_arm_required",
"code_format",
"color_modes",
"device_class",
"editable",
"effect_list",
@@ -36,6 +36,30 @@ const MEASUREMENT_VARIANTS: Variant[] = [
},
];
const MEASUREMENT_ANGLE_VARIANTS: Variant[] = [
{
labelKey: "last_24h",
days_to_show: 1,
period: "hour",
chart_type: "line",
stat_types: ["mean"],
},
{
labelKey: "last_7d",
days_to_show: 7,
period: "day",
chart_type: "line",
stat_types: ["mean"],
},
{
labelKey: "last_30d",
days_to_show: 30,
period: "day",
chart_type: "line",
stat_types: ["mean"],
},
];
const TOTAL_VARIANTS: Variant[] = [
{
labelKey: "last_7d",
@@ -60,6 +84,13 @@ const TOTAL_VARIANTS: Variant[] = [
},
];
const VARIANTS_BY_STATE_CLASS: Record<string, Variant[]> = {
measurement: MEASUREMENT_VARIANTS,
measurement_angle: MEASUREMENT_ANGLE_VARIANTS,
total: TOTAL_VARIANTS,
total_increasing: TOTAL_VARIANTS,
};
export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGraphCardConfig> =
{
getEntitySuggestion(hass, entityId) {
@@ -67,8 +98,8 @@ export const statisticsGraphCardSuggestions: CardSuggestionProvider<StatisticsGr
const stateObj = hass.states[entityId];
const stateClass = stateObj?.attributes.state_class;
if (!stateClass) return null;
const variants =
stateClass === "measurement" ? MEASUREMENT_VARIANTS : TOTAL_VARIANTS;
const variants = VARIANTS_BY_STATE_CLASS[stateClass];
if (!variants) return null;
const suggestions: CardSuggestion<StatisticsGraphCardConfig>[] =
variants.map((v) => ({
label: hass.localize(`${LABEL_PREFIX}${v.labelKey}` as any),
@@ -521,7 +521,7 @@ export function generateEnergyDevicesDetailGraphData(
true,
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
return {
chartData: datasets,
@@ -119,7 +119,7 @@ export function generateEnergyGasGraphData(
true,
generateFillBuckets(datasets, start, end, period)
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
const chartData = datasets;
const total = processTotal(energyData.stats, gasSources);
@@ -146,7 +146,7 @@ export function generateEnergySolarGraphData(
end,
compareStart,
compareEnd,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax, true),
};
}
@@ -59,6 +59,8 @@ const stackOrder = {
to_grid: 2,
used_solar: 3,
used_battery: 4,
from_grid: 5,
used_grid: 5,
};
@customElement("hui-energy-usage-graph-card")
@@ -461,7 +463,7 @@ export class HuiEnergyUsageGraphCard
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
this._chartData = datasets;
this._legendData = this._getLegendData(datasets);
this._total = this._processTotal(consumption);
@@ -274,7 +274,7 @@ export class HuiEnergyWaterGraphCard
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, waterSources);
}
@@ -261,7 +261,7 @@ export function generatePowerSourcesGraphData(
const end = energyData.end || endOfToday();
const chartData = fillLineGaps(datasets);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax, true);
const usageData: NonNullable<LineSeriesOption["data"]> = [];
// fillLineGaps ensures all datasets share the same x values, so iterate the
@@ -22,6 +22,9 @@ import type {
} from "../../../data/lovelace/config/action";
import type { ServiceAction } from "../../../data/script";
import type { HomeAssistant } from "../../../types";
import { canToggleState } from "../../../common/entity/can_toggle_state";
import { canToggleDomain } from "../../../common/entity/can_toggle_domain";
import { computeDomain } from "../../../common/entity/compute_domain";
export type UiAction = Exclude<ActionConfig["action"], "fire-dom-event">;
@@ -134,15 +137,35 @@ export class HuiActionEditor extends LitElement {
]
);
private _filterToggleAction = memoizeOne(
(actions: UiAction[]): UiAction[] => {
return actions.filter((a) => a !== "toggle");
}
);
protected render() {
if (!this.hass) {
return nothing;
}
const actions = this.actions ?? DEFAULT_ACTIONS;
let action = this.config?.action || (this.required ? "" : "default");
let actions = this.actions ?? DEFAULT_ACTIONS;
if (
this.context?.entity_id &&
action !== "toggle" &&
actions.includes("toggle")
) {
const stateObj = this.hass.states[this.context.entity_id];
const canToggle = stateObj
? canToggleState(this.hass, stateObj)
: canToggleDomain(this.hass, computeDomain(this.context.entity_id));
if (!canToggle) {
actions = this._filterToggleAction(actions);
}
}
if (action === "call-service") {
action = "perform-action";
}
@@ -2,11 +2,13 @@ import type { HaFormSchema } from "../../../../components/ha-form/types";
interface CustomizableListSchemaParams {
field: string;
customize: boolean;
options: { value: string; label: string }[];
}
export const customizableListSchema = ({
field,
customize,
options,
}: CustomizableListSchemaParams) =>
[
@@ -14,18 +16,21 @@ export const customizableListSchema = ({
name: "customize",
selector: { boolean: {} },
},
{
name: field,
hidden: { field: "customize", value: false },
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
...(customize
? ([
{
name: field,
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[];
// `customize` is form-only and never stored in the config.
@@ -37,7 +37,11 @@ export class HuiAlarmModesCardFeatureEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, stateObj: HassEntity | undefined) =>
(
localize: LocalizeFunc,
stateObj: HassEntity | undefined,
customizeModes: boolean
) =>
[
{
name: "customize_modes",
@@ -45,24 +49,27 @@ export class HuiAlarmModesCardFeatureEditor
boolean: {},
},
},
{
name: "modes",
hidden: { field: "customize_modes", value: false },
selector: {
select: {
multiple: true,
reorder: true,
options: stateObj
? supportedAlarmModes(stateObj).map((mode) => ({
value: mode,
label: `${localize(
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
)}`,
}))
: [],
},
},
},
...(customizeModes
? ([
{
name: "modes",
selector: {
select: {
multiple: true,
reorder: true,
options: stateObj
? supportedAlarmModes(stateObj).map((mode) => ({
value: mode,
label: `${localize(
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
)}`,
}))
: [],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[]
);
@@ -80,7 +87,11 @@ export class HuiAlarmModesCardFeatureEditor
? this.hass.states[this.context?.entity_id]
: undefined;
const schema = this._schema(this.hass.localize, stateObj);
const schema = this._schema(
this.hass.localize,
stateObj,
data.customize_modes
);
return html`
<ha-form
@@ -2,7 +2,6 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
@@ -33,12 +32,13 @@ export class HuiCounterActionsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((localize: LocalizeFunc) =>
private _schema = memoizeOne((customize: boolean) =>
customizableListSchema({
field: "actions",
customize,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: localize(
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
),
})),
@@ -51,7 +51,7 @@ export class HuiCounterActionsCardFeatureEditor
}
const data = customizableListData(this._config, "actions");
const schema = this._schema(this.hass.localize);
const schema = this._schema(data.customize);
return html`
<ha-form
@@ -61,44 +61,38 @@ export class HuiEnergyDevicesCardEditor
this._config = config;
}
private _schema = memoizeOne((localize: LocalizeFunc) => {
private _schema = memoizeOne((localize: LocalizeFunc, type: string) => {
const schema: HaFormSchema[] = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "modes",
required: false,
hidden: {
field: "type",
operator: "not_eq",
value: "energy-devices-graph",
},
selector: {
select: {
multiple: true,
mode: "list",
options: chartModeOpts.map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
),
})),
},
},
},
{
name: "hide_compound_stats",
required: false,
hidden: {
field: "type",
operator: "not_eq",
value: "energy-devices-graph",
},
selector: { boolean: {} },
},
...(type === "energy-devices-graph"
? ([
{
name: "modes",
required: false,
selector: {
select: {
multiple: true,
mode: "list",
options: chartModeOpts.map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
),
})),
},
},
},
{
name: "hide_compound_stats",
required: false,
selector: { boolean: {} },
},
] as HaFormSchema[])
: []),
{
name: "max_devices",
required: false,
@@ -120,7 +114,7 @@ export class HuiEnergyDevicesCardEditor
return nothing;
}
const schema = this._schema(this.hass.localize);
const schema = this._schema(this.hass.localize, this._config.type);
const data = {
...this._config,
@@ -10,6 +10,7 @@ import {
string,
union,
} from "superstruct";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../components/ha-form/types";
@@ -23,36 +24,6 @@ import type {
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const SCHEMA: HaFormSchema[] = [
{
name: "title",
hidden: { field: "type", value: "energy-compare" },
selector: { text: {} },
},
{
name: "show_legend",
hidden: {
field: "type",
operator: "not_in",
value: ["power-sources-graph", "energy-usage-graph"],
},
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "link_dashboard",
hidden: { field: "type", operator: "not_eq", value: "energy-distribution" },
required: false,
selector: { boolean: {} },
},
{
type: "string",
name: "collection_key",
required: false,
},
];
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -101,11 +72,46 @@ export class HuiEnergyGraphCardEditor
this._config = config;
}
private _schema = memoizeOne((type: string) => {
const schema: HaFormSchema[] = [
...(type !== "energy-compare"
? [{ name: "title", selector: { text: {} } }]
: []),
...(type === "power-sources-graph" || type === "energy-usage-graph"
? [
{
name: "show_legend",
default: true,
required: false,
selector: { boolean: {} },
},
]
: []),
...(type === "energy-distribution"
? [
{
name: "link_dashboard",
required: false,
selector: { boolean: {} },
},
]
: []),
{
type: "string",
name: "collection_key",
required: false,
},
];
return schema;
});
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema(this._config.type);
const data = {
...this._config,
};
@@ -113,7 +119,7 @@ export class HuiEnergyGraphCardEditor
return html` <ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -74,7 +74,11 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
}
private _schema = memoizeOne(
(localize: LocalizeFunc, stateObj: HassEntity | undefined) => {
(
localize: LocalizeFunc,
stateObj: HassEntity | undefined,
customizeModes: boolean
) => {
const d = this.descriptor;
const styleListId = d.styleListI18nFeatureId ?? d.i18nFeatureId;
return [
@@ -99,20 +103,25 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
boolean: {},
},
},
{
name: d.modeField,
hidden: { field: "customize_modes", value: false },
selector: {
select: {
reorder: true,
multiple: true,
options: d.getAvailableModesOrdered(stateObj).map((mode) => ({
value: mode,
label: d.getModeLabel(this.hass!, stateObj, mode),
})),
},
},
},
...(customizeModes
? ([
{
name: d.modeField,
selector: {
select: {
reorder: true,
multiple: true,
options: d
.getAvailableModesOrdered(stateObj)
.map((mode) => ({
value: mode,
label: d.getModeLabel(this.hass!, stateObj, mode),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[];
}
);
@@ -134,7 +143,11 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
customize_modes: this._config[modeField as keyof TConfig] !== undefined,
};
const schema = this._schema(this.hass.localize, stateObj);
const schema = this._schema(
this.hass.localize,
stateObj,
data.customize_modes
);
return html`
<ha-form
@@ -80,7 +80,7 @@ export class HuiGaugeCardEditor
}
private _schema = memoizeOne(
(entityId?: string) =>
(showSeverity: boolean, entityId?: string) =>
[
{
name: "entity",
@@ -132,25 +132,28 @@ export class HuiGaugeCardEditor
{ name: "show_severity", selector: { boolean: {} } },
],
},
{
name: "severity",
type: "grid",
hidden: { field: "show_severity", value: false },
schema: [
{
name: "green",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "yellow",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "red",
selector: { number: { mode: "box", step: "any" } },
},
],
},
...(showSeverity
? ([
{
name: "severity",
type: "grid",
schema: [
{
name: "green",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "yellow",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "red",
selector: { number: { mode: "box", step: "any" } },
},
],
},
] as const)
: []),
{
name: "interactions",
type: "expandable",
@@ -194,7 +197,10 @@ export class HuiGaugeCardEditor
return nothing;
}
const schema = this._schema(this._config!.entity);
const schema = this._schema(
this._config!.severity !== undefined,
this._config!.entity
);
const data = {
show_severity: this._config!.severity !== undefined,
...this._config,
@@ -34,18 +34,20 @@ export class HuiLawnMowerCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -58,7 +60,7 @@ export class HuiLawnMowerCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj);
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
@@ -51,7 +51,7 @@ export class HuiMarkdownCardEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
(localize: LocalizeFunc, text_only: boolean) =>
[
{
name: "style",
@@ -73,11 +73,9 @@ export class HuiMarkdownCardEditor
},
},
},
{
name: "title",
hidden: { field: "style", value: "text-only" },
selector: { text: {} },
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{
name: "content",
required: true,
@@ -133,7 +131,10 @@ export class HuiMarkdownCardEditor
style: this._config.text_only ? "text-only" : "card",
};
const schema = this._schema(this.hass.localize);
const schema = this._schema(
this.hass.localize,
this._config.text_only || false
);
return html`
<ha-form
@@ -38,10 +38,11 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined) =>
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
[
...customizableListSchema({
field: "controls",
customize,
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
(control) =>
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
@@ -68,7 +69,7 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "controls");
const schema = this._schema(stateObj);
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
@@ -32,19 +32,21 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sound_modes",
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sound_modes",
customize,
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
);
protected render() {
@@ -58,7 +60,7 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sound_modes");
const schema = this._schema(stateObj);
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
@@ -32,19 +32,21 @@ export class HuiMediaPlayerSourceCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sources",
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sources",
customize,
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
);
protected render() {
@@ -58,7 +60,7 @@ export class HuiMediaPlayerSourceCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sources");
const schema = this._schema(stateObj);
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
@@ -38,10 +38,15 @@ export class HuiPrecipitationForecastCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -62,22 +67,17 @@ export class HuiPrecipitationForecastCardFeatureEditor
},
},
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
hidden: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
hidden: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
@@ -141,7 +141,7 @@ export class HuiPrecipitationForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, this.hass.localize);
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
return html`
<ha-form
@@ -38,7 +38,8 @@ export class HuiSelectOptionsCardFeatureEditor
private _schema = memoizeOne(
(
formatEntityState: FormatEntityStateFunc,
stateObj: HassEntity | undefined
stateObj: HassEntity | undefined,
customizeOptions: boolean
) =>
[
{
@@ -47,21 +48,24 @@ export class HuiSelectOptionsCardFeatureEditor
boolean: {},
},
},
{
name: "options",
hidden: { field: "customize_options", value: false },
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
...(customizeOptions
? ([
{
name: "options",
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[]
);
@@ -79,7 +83,11 @@ export class HuiSelectOptionsCardFeatureEditor
customize_options: this._config.options !== undefined,
};
const schema = this._schema(this.hass.formatEntityState, stateObj);
const schema = this._schema(
this.hass.formatEntityState,
stateObj,
data.customize_options
);
return html`
<ha-form
@@ -157,6 +157,8 @@ export class HuiStatisticsGraphCardEditor
localize: LocalizeFunc,
statisticIds: string[] | undefined,
metaDatas: StatisticsMetaData[] | undefined,
showFitOption: boolean,
hiddenLegend: boolean,
enableDateSelect: boolean
) => {
const units = new Set<string>();
@@ -289,18 +291,15 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
],
},
selector: { boolean: {} },
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
{
name: "logarithmic_scale",
required: false,
@@ -311,12 +310,15 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
hidden: { field: "hide_legend", value: true },
selector: { boolean: {} },
},
...(!hiddenLegend
? [
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
]
: []),
],
},
],
@@ -395,6 +397,9 @@ export class HuiStatisticsGraphCardEditor
this.hass.localize,
this._configEntities,
this._metaDatas,
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined,
!!this._config!.hide_legend,
!!this._config!.energy_date_selection
);
const configured_stat_types = this._config!.stat_types
@@ -38,10 +38,15 @@ export class HuiTemperatureForecastCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -62,22 +67,17 @@ export class HuiTemperatureForecastCardFeatureEditor
},
},
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
hidden: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
hidden: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
@@ -117,7 +117,7 @@ export class HuiTemperatureForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, this.hass.localize);
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
return html`
<ha-form
@@ -37,18 +37,20 @@ export class HuiVacuumCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -61,7 +63,7 @@ export class HuiVacuumCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj);
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
@@ -137,7 +137,6 @@ export class HuiDialogEditSection
case "tab-settings":
content = html`
<hui-section-settings-editor
.hass=${this.hass}
.config=${this._config}
.viewConfig=${this._viewConfig}
@value-changed=${this._configChanged}
@@ -2,6 +2,7 @@ import { mdiPalette } from "@mdi/js";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
@@ -15,7 +16,6 @@ import {
type LovelaceSectionRawConfig,
} from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
interface SettingsData {
column_span?: number;
@@ -27,7 +27,8 @@ interface SettingsData {
@customElement("hui-section-settings-editor")
export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@@ -113,12 +114,11 @@ export class HuiDialogEditSection extends LitElement {
const schema = this._schema(
this.viewConfig.max_columns || 4,
this.hass.localize
this._localize
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabel}
@@ -131,14 +131,14 @@ export class HuiDialogEditSection extends LitElement {
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
this._localize(
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}`
);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
this._localize(
`ui.panel.lovelace.editor.edit_section.settings.${schema.name}_helper`
) || "";
@@ -170,7 +170,6 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
content = html`
<hui-view-editor
.isNew=${this._params.viewIndex === undefined}
.hass=${this.hass}
.config=${this._config}
@view-config-changed=${this._viewConfigChanged}
></hui-view-editor>
@@ -1,6 +1,7 @@
import { html, LitElement, nothing } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import type { LocalizeFunc } from "../../../../common/translations/localize";
@@ -10,7 +11,6 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import {
MASONRY_VIEW_LAYOUT,
SECTIONS_VIEW_LAYOUT,
@@ -33,7 +33,8 @@ const INTEGER_REGEX = /^[0-9]+$/;
@customElement("hui-view-editor")
export class HuiViewEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public isNew = false;
@@ -135,12 +136,6 @@ export class HuiViewEditor extends LitElement {
}
protected render() {
if (!this.hass) {
return nothing;
}
const schema = this._schema(this.hass.localize);
const data = {
...this._config,
type: this._type,
@@ -156,9 +151,8 @@ export class HuiViewEditor extends LitElement {
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.schema=${this._schema(this._localize)}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.computeError=${this._computeError}
@@ -208,14 +202,14 @@ export class HuiViewEditor extends LitElement {
}
private _computeError = (error: string) =>
this.hass.localize(`ui.panel.lovelace.editor.edit_view.${error}`) || error;
this._localize(`ui.panel.lovelace.editor.edit_view.${error}`) || error;
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "path":
return this.hass!.localize("ui.panel.lovelace.editor.card.generic.url");
return this._localize("ui.panel.lovelace.editor.card.generic.url");
case "type":
case "show_icon_and_title":
case "subview":
@@ -223,11 +217,11 @@ export class HuiViewEditor extends LitElement {
case "dense_section_placement":
case "top_margin":
case "section_specifics":
return this.hass.localize(
return this._localize(
`ui.panel.lovelace.editor.edit_view.${schema.name}`
);
default:
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
@@ -242,7 +236,7 @@ export class HuiViewEditor extends LitElement {
case "subview":
case "dense_section_placement":
case "top_margin":
return this.hass.localize(
return this._localize(
`ui.panel.lovelace.editor.edit_view.${schema.name}_helper`
);
+6 -1
View File
@@ -348,6 +348,7 @@
"temperature": "Temperature",
"visibility": "Visibility",
"wind_speed": "Wind speed",
"wind_gust_speed": "Wind gust speed",
"precipitation": "Precipitation"
},
"cardinal_direction": {
@@ -2511,7 +2512,11 @@
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings."
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
"onboarding_survey": {
"message": "Hello there! 👋 You've been using Home Assistant for a little while now. We'd love to hear what you think. Just one quick minute!",
"action": "Take survey"
}
},
"sidebar": {
"external_app_configuration": "App settings",
+25 -13
View File
@@ -1,6 +1,9 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsFeature } from "../common/entity/supports-feature";
import { cleanupMediaTitle } from "../data/media-player";
import {
cleanupMediaTitle,
MediaPlayerEntityFeature,
} from "../data/media-player";
import type { HomeAssistant } from "../types";
export default class MediaPlayerEntity {
@@ -75,51 +78,60 @@ export default class MediaPlayerEntity {
}
get supportsPause() {
return supportsFeature(this.stateObj, 1);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PAUSE);
}
get supportsVolumeSet() {
return supportsFeature(this.stateObj, 4);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_SET);
}
get supportsVolumeMute() {
return supportsFeature(this.stateObj, 8);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_MUTE);
}
get supportsPreviousTrack() {
return supportsFeature(this.stateObj, 16);
return supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.PREVIOUS_TRACK
);
}
get supportsNextTrack() {
return supportsFeature(this.stateObj, 32);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.NEXT_TRACK);
}
get supportsTurnOn() {
return supportsFeature(this.stateObj, 128);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_ON);
}
get supportsTurnOff() {
return supportsFeature(this.stateObj, 256);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_OFF);
}
get supportsPlayMedia() {
return supportsFeature(this.stateObj, 512);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY_MEDIA);
}
get supportsVolumeButtons() {
return supportsFeature(this.stateObj, 1024);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_STEP);
}
get supportsSelectSource() {
return supportsFeature(this.stateObj, 2048);
return supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.SELECT_SOURCE
);
}
get supportsSelectSoundMode() {
return supportsFeature(this.stateObj, 65536);
return supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.SELECT_SOUND_MODE
);
}
get supportsPlay() {
return supportsFeature(this.stateObj, 16384);
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY);
}
get primaryTitle() {
+84
View File
@@ -0,0 +1,84 @@
import type { Connection } from "home-assistant-js-websocket";
import type {
CoreFrontendSystemData,
SurveyInteraction,
} from "../data/frontend";
import { saveFrontendSystemData } from "../data/frontend";
import type { CurrentUser, HomeAssistant } from "../types";
import { showToast } from "./toast";
const SURVEY_MIN_AGE = 5 * 24 * 60 * 60 * 1000; // 5 days
const SURVEY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month
// Always the production site: the survey is a single page that is not
// version-specific (the version is passed as a query parameter instead), so
// beta/dev installs should not be routed to rc./next. like documentationUrl
// would.
const SURVEY_URL = "https://www.home-assistant.io/surveys/onboarding";
export const shouldShowOnboardingSurvey = (
user: CurrentUser | undefined,
systemData: CoreFrontendSystemData | undefined,
now: number = Date.now()
): boolean => {
if (!user?.is_owner || !systemData?.onboarded_date) {
return false;
}
if (systemData.surveys?.onboarding) {
return false;
}
const age = now - new Date(systemData.onboarded_date).getTime();
// NaN (invalid date) and future dates (clock skew) both fail these checks
return age >= SURVEY_MIN_AGE && age <= SURVEY_MAX_AGE;
};
export const recordOnboardingSurvey = (
conn: Connection,
systemData: CoreFrontendSystemData,
action: SurveyInteraction["action"]
): Promise<void> =>
// saveFrontendSystemData overwrites the whole "core" object, so spread the
// existing data to preserve the other fields.
saveFrontendSystemData(conn, "core", {
...systemData,
surveys: {
...systemData.surveys,
onboarding: { date: new Date().toISOString(), action },
},
});
export const getOnboardingSurveyUrl = (
systemData: CoreFrontendSystemData
): string =>
systemData.onboarded_version
? `${SURVEY_URL}?version=${encodeURIComponent(systemData.onboarded_version)}`
: SURVEY_URL;
export const checkOnboardingSurveyToast = (
el: HTMLElement,
hass: HomeAssistant
) => {
if (!shouldShowOnboardingSurvey(hass.user, hass.systemData)) {
return;
}
const record = (action: SurveyInteraction["action"]) =>
recordOnboardingSurvey(hass.connection, hass.systemData!, action);
showToast(el, {
id: "onboarding-survey",
message: {
translationKey: "ui.notification_toast.onboarding_survey.message",
},
duration: -1,
dismissable: true,
action: {
text: {
translationKey: "ui.notification_toast.onboarding_survey.action",
},
action: () => {
window.open(getOnboardingSurveyUrl(hass.systemData!), "_blank");
record("opened");
},
},
dismiss: () => record("dismissed"),
});
};
+3 -1
View File
@@ -1,6 +1,7 @@
import { assert, describe, it } from "vitest";
import { canToggleState } from "../../../src/common/entity/can_toggle_state";
import { ClimateEntityFeature } from "../../../src/data/climate";
describe("canToggleState", () => {
const hass: any = {
@@ -48,7 +49,8 @@ describe("canToggleState", () => {
const stateObj: any = {
entity_id: "climate.bla",
attributes: {
supported_features: 4096,
supported_features:
ClimateEntityFeature.TURN_ON + ClimateEntityFeature.TURN_OFF,
},
};
assert.isTrue(canToggleState(hass, stateObj));
+1 -1
View File
@@ -132,7 +132,7 @@ describe("getStates", () => {
expect.arrayContaining([
"battery",
"battery_charging",
"co",
"carbon_monoxide",
"cold",
"connectivity",
"door",
@@ -43,6 +43,23 @@ describe("computeYAxisFractionDigits", () => {
expect(computeYAxisFractionDigits(1.5, 1.5)).toBe(1);
});
it("treats a floating-point-noise range as flat (issue #53180)", () => {
expect(computeYAxisFractionDigits(0.3, 0.3 + 1e-16)).toBe(1);
expect(computeYAxisFractionDigits(0.2, 0.20000000000004547)).toBe(1);
expect(computeYAxisFractionDigits(1_000_000, 1_000_000.00001)).toBe(1);
});
it("keeps precision for a genuinely narrow, non-noise range", () => {
expect(computeYAxisFractionDigits(1e-6, 3e-6)).toBe(7);
});
it("unions the extent with zero for anchored (bar) axes", () => {
expect(computeYAxisFractionDigits(0.3, 0.3, true)).toBe(2);
expect(
computeYAxisFractionDigits(0.29999999999999993, 0.3000000000000001, true)
).toBe(2);
});
it("falls back to one decimal when range is non-finite", () => {
expect(computeYAxisFractionDigits(Infinity, -Infinity)).toBe(1);
expect(computeYAxisFractionDigits(NaN, 1)).toBe(1);
@@ -112,4 +129,12 @@ describe("createYAxisPrecisionBounds", () => {
min({ min: 0.02, max: 0.05 });
expect(onFractionDigits).toHaveBeenLastCalledWith(2);
});
it("does not over-pad when the visible extent collapses to noise", () => {
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
min({ min: 0.3, max: 0.3 + 1e-15 });
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
});
});
+67 -461
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);
});
}
});
@@ -521,144 +244,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(
+188
View File
@@ -0,0 +1,188 @@
import type { Connection } from "home-assistant-js-websocket";
import {
afterEach,
assert,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import type { CoreFrontendSystemData } from "../../src/data/frontend";
import type { CurrentUser } from "../../src/types";
import {
getOnboardingSurveyUrl,
recordOnboardingSurvey,
shouldShowOnboardingSurvey,
} from "../../src/util/onboarding-survey";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date("2026-07-20T12:00:00.000Z").getTime();
const daysAgo = (days: number) => new Date(NOW - days * DAY).toISOString();
const owner = { is_owner: true } as CurrentUser;
const nonOwner = { is_owner: false } as CurrentUser;
describe("shouldShowOnboardingSurvey", () => {
it("returns false without a user", () => {
assert.isFalse(
shouldShowOnboardingSurvey(undefined, { onboarded_date: daysAgo(6) }, NOW)
);
});
it("returns false for a non-owner user", () => {
assert.isFalse(
shouldShowOnboardingSurvey(nonOwner, { onboarded_date: daysAgo(6) }, NOW)
);
});
it("returns false without system data", () => {
assert.isFalse(shouldShowOnboardingSurvey(owner, undefined, NOW));
assert.isFalse(shouldShowOnboardingSurvey(owner, {}, NOW));
});
it("returns false when onboarded less than 5 days ago", () => {
assert.isFalse(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(4) }, NOW)
);
});
it("returns false when onboarded more than 30 days ago", () => {
assert.isFalse(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(31) }, NOW)
);
});
it("returns false for an invalid date", () => {
assert.isFalse(
shouldShowOnboardingSurvey(owner, { onboarded_date: "not-a-date" }, NOW)
);
});
it("returns false for a date in the future", () => {
assert.isFalse(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(-1) }, NOW)
);
});
it("returns false when already interacted with", () => {
assert.isFalse(
shouldShowOnboardingSurvey(
owner,
{
onboarded_date: daysAgo(6),
surveys: { onboarding: { date: daysAgo(1), action: "dismissed" } },
},
NOW
)
);
assert.isFalse(
shouldShowOnboardingSurvey(
owner,
{
onboarded_date: daysAgo(6),
surveys: { onboarding: { date: daysAgo(1), action: "opened" } },
},
NOW
)
);
});
it("returns true within the 5-30 day window", () => {
assert.isTrue(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(5) }, NOW)
);
assert.isTrue(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(15) }, NOW)
);
assert.isTrue(
shouldShowOnboardingSurvey(owner, { onboarded_date: daysAgo(30) }, NOW)
);
});
it("returns true when surveys exists without the onboarding flag", () => {
assert.isTrue(
shouldShowOnboardingSurvey(
owner,
{ onboarded_date: daysAgo(6), surveys: {} },
NOW
)
);
});
});
describe("recordOnboardingSurvey", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("stores the interaction and preserves existing fields", async () => {
const sendMessagePromise = vi.fn().mockResolvedValue(undefined);
const conn = { sendMessagePromise } as unknown as Connection;
const systemData: CoreFrontendSystemData = {
default_panel: "lovelace",
onboarded_version: "2026.7.0",
onboarded_date: daysAgo(6),
};
await recordOnboardingSurvey(conn, systemData, "dismissed");
expect(sendMessagePromise).toHaveBeenCalledWith({
type: "frontend/set_system_data",
key: "core",
value: {
default_panel: "lovelace",
onboarded_version: "2026.7.0",
onboarded_date: daysAgo(6),
surveys: {
onboarding: {
date: new Date(NOW).toISOString(),
action: "dismissed",
},
},
},
});
});
it("merges an existing surveys object", async () => {
const sendMessagePromise = vi.fn().mockResolvedValue(undefined);
const conn = { sendMessagePromise } as unknown as Connection;
await recordOnboardingSurvey(conn, { surveys: {} }, "opened");
expect(sendMessagePromise).toHaveBeenCalledWith({
type: "frontend/set_system_data",
key: "core",
value: {
surveys: {
onboarding: { date: new Date(NOW).toISOString(), action: "opened" },
},
},
});
});
});
describe("getOnboardingSurveyUrl", () => {
it("includes the version param and ignores the onboard date", () => {
assert.strictEqual(
getOnboardingSurveyUrl({
onboarded_version: "2026.7.0",
onboarded_date: "2026-07-14T12:00:00.000Z",
}),
"https://www.home-assistant.io/surveys/onboarding?version=2026.7.0"
);
});
it("omits a missing version param", () => {
assert.strictEqual(
getOnboardingSurveyUrl({}),
"https://www.home-assistant.io/surveys/onboarding"
);
});
});
+17 -197
View File
@@ -3417,7 +3417,7 @@ __metadata:
languageName: node
linkType: hard
"@lit/context@npm:1.1.6":
"@lit/context@npm:1.1.6, @lit/context@npm:^1.1.6":
version: 1.1.6
resolution: "@lit/context@npm:1.1.6"
dependencies:
@@ -3753,13 +3753,14 @@ __metadata:
languageName: node
linkType: hard
"@material/web@npm:2.4.1":
version: 2.4.1
resolution: "@material/web@npm:2.4.1"
"@material/web@npm:2.5.0":
version: 2.5.0
resolution: "@material/web@npm:2.5.0"
dependencies:
"@lit/context": "npm:^1.1.6"
lit: "npm:^2.8.0 || ^3.0.0"
tslib: "npm:^2.4.0"
checksum: 10/6f9afeb28faa7d9720228cd3f46d15116b6f8dce6e61ebebf4acb0e720389724d5aa9e31f612bfa24afc5d49f283efd4660337e3d4d7379aec6b4286a01eba71
checksum: 10/3864bce2034413e2f712ee663bbaf3db2ae24af30f758ea7f6b66b7a7756671e868b197ad8d2aa371439b664ad0e0db6c587981fe589a22322b6ccbb2f28fa2e
languageName: node
linkType: hard
@@ -6689,15 +6690,6 @@ __metadata:
languageName: node
linkType: hard
"ansi-escapes@npm:^7.0.0":
version: 7.3.0
resolution: "ansi-escapes@npm:7.3.0"
dependencies:
environment: "npm:^1.0.0"
checksum: 10/189e23e75aacf00ee647ba0545687456cc4bc62547dd0cf6c7728f91fce88854c8e885d5819a278b39981bb846d9427693d2380c562aecdb0cf91d3342141e93
languageName: node
linkType: hard
"ansi-gray@npm:^0.1.1":
version: 0.1.1
resolution: "ansi-gray@npm:0.1.1"
@@ -6739,7 +6731,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3":
"ansi-styles@npm:^6.1.0":
version: 6.2.3
resolution: "ansi-styles@npm:6.2.3"
checksum: 10/c49dad7639f3e48859bd51824c93b9eb0db628afc243c51c3dd2410c4a15ede1a83881c6c7341aa2b159c4f90c11befb38f2ba848c07c66c9f9de4bcd7cb9f30
@@ -7557,15 +7549,6 @@ __metadata:
languageName: node
linkType: hard
"cli-cursor@npm:^5.0.0":
version: 5.0.0
resolution: "cli-cursor@npm:5.0.0"
dependencies:
restore-cursor: "npm:^5.0.0"
checksum: 10/1eb9a3f878b31addfe8d82c6d915ec2330cec8447ab1f117f4aa34f0137fbb3137ec3466e1c9a65bcb7557f6e486d343f2da57f253a2f668d691372dfa15c090
languageName: node
linkType: hard
"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.0":
version: 2.9.2
resolution: "cli-spinners@npm:2.9.2"
@@ -7573,16 +7556,6 @@ __metadata:
languageName: node
linkType: hard
"cli-truncate@npm:^5.2.0":
version: 5.2.0
resolution: "cli-truncate@npm:5.2.0"
dependencies:
slice-ansi: "npm:^8.0.0"
string-width: "npm:^8.2.0"
checksum: 10/b789b6c2caff1560259aedeb6aaafcf41167d478df418d718a8c92edd6bc5a0ece272b8fb7e7911fbd31cef7b1ac8a30f2b21d90c3174b55a018fe3f2604a137
languageName: node
linkType: hard
"clipboardy@npm:3.0.0":
version: 3.0.0
resolution: "clipboardy@npm:3.0.0"
@@ -8423,7 +8396,7 @@ __metadata:
languageName: node
linkType: hard
"emoji-regex@npm:^10.2.1, emoji-regex@npm:^10.3.0":
"emoji-regex@npm:^10.2.1":
version: 10.6.0
resolution: "emoji-regex@npm:10.6.0"
checksum: 10/98cc0b0e1daed1ed25afbf69dcb921fee00f712f51aab93aa1547e4e4e8171725cc4f0098aaa645b4f611a19da11ec9f4623eb6ff2b72314b39a8f2ae7c12bf2
@@ -8543,13 +8516,6 @@ __metadata:
languageName: node
linkType: hard
"environment@npm:^1.0.0":
version: 1.1.0
resolution: "environment@npm:1.1.0"
checksum: 10/dd3c1b9825e7f71f1e72b03c2344799ac73f2e9ef81b78ea8b373e55db021786c6b9f3858ea43a436a2c4611052670ec0afe85bc029c384cc71165feee2f4ba6
languageName: node
linkType: hard
"error-ex@npm:^1.3.1":
version: 1.3.4
resolution: "error-ex@npm:1.3.4"
@@ -9030,13 +8996,6 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^5.0.4":
version: 5.0.4
resolution: "eventemitter3@npm:5.0.4"
checksum: 10/54f5c8c543650d65f92d03dbef1bb73a682a920490c44699ad8f863a6b19bbca42fb7409aa09ca09cb98a44149d9a7bc1dffd55ca88a740bd928c7be0ad666a0
languageName: node
linkType: hard
"events-universal@npm:^1.0.0":
version: 1.0.1
resolution: "events-universal@npm:1.0.1"
@@ -9539,13 +9498,6 @@ __metadata:
languageName: node
linkType: hard
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0":
version: 1.6.0
resolution: "get-east-asian-width@npm:1.6.0"
checksum: 10/3e5370b5df1f0020db711d8a3f9ee2cbfc9c7542daa99a699e9d7b9acf66e7868b89084741565a45d30d80afedf6e1218e0fb8bef7a583924a449c2816777380
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
@@ -9994,7 +9946,7 @@ __metadata:
"@lokalise/node-api": "npm:16.3.0"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"@material/web": "npm:2.4.1"
"@material/web": "npm:2.5.0"
"@mdi/js": "npm:7.4.47"
"@mdi/svg": "npm:7.4.47"
"@octokit/auth-oauth-device": "npm:8.0.3"
@@ -10078,7 +10030,7 @@ __metadata:
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.0.8"
lint-staged: "npm:17.1.0"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -10591,15 +10543,6 @@ __metadata:
languageName: node
linkType: hard
"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0":
version: 5.1.0
resolution: "is-fullwidth-code-point@npm:5.1.0"
dependencies:
get-east-asian-width: "npm:^1.3.1"
checksum: 10/4700d8a82cb71bd2a2955587b2823c36dc4660eadd4047bfbd070821ddbce8504fc5f9b28725567ecddf405b1e06c6692c9b719f65df6af9ec5262bc11393a6a
languageName: node
linkType: hard
"is-generator-function@npm:^1.0.10":
version: 1.1.2
resolution: "is-generator-function@npm:1.1.2"
@@ -11480,12 +11423,11 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.0.8":
version: 17.0.8
resolution: "lint-staged@npm:17.0.8"
"lint-staged@npm:17.1.0":
version: 17.1.0
resolution: "lint-staged@npm:17.1.0"
dependencies:
listr2: "npm:^10.2.1"
picomatch: "npm:^4.0.4"
picomatch: "npm:^4.0.5"
string-argv: "npm:^0.3.2"
tinyexec: "npm:^1.2.4"
yaml: "npm:^2.9.0"
@@ -11494,20 +11436,7 @@ __metadata:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/2b574a3107c030e27ff1c34166ef49f2189c256bb423b0deabef0becdf13ed4cfdcc6fb6815a1285ce0daa92fc6c545d8d0245c9d47a8eb3fbccbc4cf3754587
languageName: node
linkType: hard
"listr2@npm:^10.2.1":
version: 10.2.2
resolution: "listr2@npm:10.2.2"
dependencies:
cli-truncate: "npm:^5.2.0"
eventemitter3: "npm:^5.0.4"
log-update: "npm:^6.1.0"
rfdc: "npm:^1.4.1"
wrap-ansi: "npm:^10.0.0"
checksum: 10/e9589e1cc8b2158d54dab1afe43f014fab443400a02096ee6914f83f64ca5aea56b72dd39582916c06e4b50351587964ff20a428994f61cec1b9160b92c70093
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
languageName: node
linkType: hard
@@ -11657,19 +11586,6 @@ __metadata:
languageName: node
linkType: hard
"log-update@npm:^6.1.0":
version: 6.1.0
resolution: "log-update@npm:6.1.0"
dependencies:
ansi-escapes: "npm:^7.0.0"
cli-cursor: "npm:^5.0.0"
slice-ansi: "npm:^7.1.0"
strip-ansi: "npm:^7.1.0"
wrap-ansi: "npm:^9.0.0"
checksum: 10/5abb4131e33b1e7f8416bb194fe17a3603d83e4657c5bf5bb81ce4187f3b00ea481643b85c3d5cefe6037a452cdcf7f1391ab8ea0d9c23e75d19589830ec4f11
languageName: node
linkType: hard
"lower-case@npm:^2.0.2":
version: 2.0.2
resolution: "lower-case@npm:2.0.2"
@@ -11888,13 +11804,6 @@ __metadata:
languageName: node
linkType: hard
"mimic-function@npm:^5.0.0":
version: 5.0.1
resolution: "mimic-function@npm:5.0.1"
checksum: 10/eb5893c99e902ccebbc267c6c6b83092966af84682957f79313311edb95e8bb5f39fb048d77132b700474d1c86d90ccc211e99bae0935447a4834eb4c882982c
languageName: node
linkType: hard
"minify-literals@npm:2.1.0":
version: 2.1.0
resolution: "minify-literals@npm:2.1.0"
@@ -12456,15 +12365,6 @@ __metadata:
languageName: node
linkType: hard
"onetime@npm:^7.0.0":
version: 7.0.0
resolution: "onetime@npm:7.0.0"
dependencies:
mimic-function: "npm:^5.0.0"
checksum: 10/eb08d2da9339819e2f9d52cab9caf2557d80e9af8c7d1ae86e1a0fef027d00a88e9f5bd67494d350df360f7c559fbb44e800b32f310fb989c860214eacbb561c
languageName: node
linkType: hard
"optionator@npm:^0.9.3":
version: 0.9.4
resolution: "optionator@npm:0.9.4"
@@ -13459,16 +13359,6 @@ __metadata:
languageName: node
linkType: hard
"restore-cursor@npm:^5.0.0":
version: 5.1.0
resolution: "restore-cursor@npm:5.1.0"
dependencies:
onetime: "npm:^7.0.0"
signal-exit: "npm:^4.1.0"
checksum: 10/838dd54e458d89cfbc1a923b343c1b0f170a04100b4ce1733e97531842d7b440463967e521216e8ab6c6f8e89df877acc7b7f4c18ec76e99fb9bf5a60d358d2c
languageName: node
linkType: hard
"reusify@npm:^1.0.4":
version: 1.1.0
resolution: "reusify@npm:1.1.0"
@@ -13476,13 +13366,6 @@ __metadata:
languageName: node
linkType: hard
"rfdc@npm:^1.4.1":
version: 1.4.1
resolution: "rfdc@npm:1.4.1"
checksum: 10/2f3d11d3d8929b4bfeefc9acb03aae90f971401de0add5ae6c5e38fec14f0405e6a4aad8fdb76344bfdd20c5193110e3750cbbd28ba86d73729d222b6cf4a729
languageName: node
linkType: hard
"roboto-fontface@npm:0.10.0":
version: 0.10.0
resolution: "roboto-fontface@npm:0.10.0"
@@ -13975,7 +13858,7 @@ __metadata:
languageName: node
linkType: hard
"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0":
"signal-exit@npm:^4.0.1":
version: 4.1.0
resolution: "signal-exit@npm:4.1.0"
checksum: 10/c9fa63bbbd7431066174a48ba2dd9986dfd930c3a8b59de9c29d7b6854ec1c12a80d15310869ea5166d413b99f041bfa3dd80a7947bcd44ea8e6eb3ffeabfa1f
@@ -14015,26 +13898,6 @@ __metadata:
languageName: node
linkType: hard
"slice-ansi@npm:^7.1.0":
version: 7.1.2
resolution: "slice-ansi@npm:7.1.2"
dependencies:
ansi-styles: "npm:^6.2.1"
is-fullwidth-code-point: "npm:^5.0.0"
checksum: 10/75f61e1285c294b18c88521a0cdb22cdcbe9b0fd5e8e26f649be804cc43122aa7751bd960a968e3ed7f5aa7f3c67ac605c939019eae916870ec288e878b6fafb
languageName: node
linkType: hard
"slice-ansi@npm:^8.0.0":
version: 8.0.0
resolution: "slice-ansi@npm:8.0.0"
dependencies:
ansi-styles: "npm:^6.2.3"
is-fullwidth-code-point: "npm:^5.1.0"
checksum: 10/6a7e146852047e26dd5857b35c767e52906549c580cce0ad2287cc32f54f5a582494f674817fc9ac21b2e4ac1ddeaa85b3dee409782681b465330278890c73a8
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
@@ -14395,27 +14258,6 @@ __metadata:
languageName: node
linkType: hard
"string-width@npm:^7.0.0":
version: 7.2.0
resolution: "string-width@npm:7.2.0"
dependencies:
emoji-regex: "npm:^10.3.0"
get-east-asian-width: "npm:^1.0.0"
strip-ansi: "npm:^7.1.0"
checksum: 10/42f9e82f61314904a81393f6ef75b832c39f39761797250de68c041d8ba4df2ef80db49ab6cd3a292923a6f0f409b8c9980d120f7d32c820b4a8a84a2598a295
languageName: node
linkType: hard
"string-width@npm:^8.2.0":
version: 8.2.2
resolution: "string-width@npm:8.2.2"
dependencies:
get-east-asian-width: "npm:^1.5.0"
strip-ansi: "npm:^7.1.2"
checksum: 10/18da8c8e5247bedd8a379272b9fccebd16ec22132486344a4a5bf3bd2acf4b12fda9b2be9192eba59c5dc1484f2d36dbfb5fd5e66950fe7167fa00876078196f
languageName: node
linkType: hard
"string.prototype.matchall@npm:^4.0.12":
version: 4.0.12
resolution: "string.prototype.matchall@npm:4.0.12"
@@ -14514,7 +14356,7 @@ __metadata:
languageName: node
linkType: hard
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2, strip-ansi@npm:^7.2.0":
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.2.0":
version: 7.2.0
resolution: "strip-ansi@npm:7.2.0"
dependencies:
@@ -16282,17 +16124,6 @@ __metadata:
languageName: node
linkType: hard
"wrap-ansi@npm:^10.0.0":
version: 10.0.0
resolution: "wrap-ansi@npm:10.0.0"
dependencies:
ansi-styles: "npm:^6.2.3"
string-width: "npm:^8.2.0"
strip-ansi: "npm:^7.1.2"
checksum: 10/b9ac5290e2c5b88cba928b72fbc04e4bb7196cebde1522eeb82a56b1e32c2e706a20169d305c182fe7e4b31100cb05bb1d98dc2747a4a98700ac44303b3ac2ce
languageName: node
linkType: hard
"wrap-ansi@npm:^6.2.0":
version: 6.2.0
resolution: "wrap-ansi@npm:6.2.0"
@@ -16326,17 +16157,6 @@ __metadata:
languageName: node
linkType: hard
"wrap-ansi@npm:^9.0.0":
version: 9.0.2
resolution: "wrap-ansi@npm:9.0.2"
dependencies:
ansi-styles: "npm:^6.2.1"
string-width: "npm:^7.0.0"
strip-ansi: "npm:^7.1.0"
checksum: 10/f3907e1ea9717404ca53a338fa5a017c2121550c3a5305180e2bc08c03e21aa45068df55b0d7676bf57be1880ba51a84458c17241ebedea485fafa9ef16b4024
languageName: node
linkType: hard
"wrappy@npm:1":
version: 1.0.2
resolution: "wrappy@npm:1.0.2"