mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-15 12:33:35 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d85ec4ed61 | |||
| 36a9da37f8 | |||
| cee5361525 | |||
| 5f46594ee2 | |||
| 58bcf1e865 | |||
| 196d71c4f7 |
@@ -1,18 +1,12 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
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.
|
||||
description: Home Assistant frontend validation workflow. Use when 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
|
||||
@@ -41,25 +35,23 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Prefer running the relevant server in the background while iterating. Playwright reuses it when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
Use the same server command with `--status`, `--logs [--follow]`, or `--stop` to manage it. Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
unique_deploy_url:
|
||||
description: The unique hash-based deployment URL for this specific build
|
||||
value: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -32,9 +35,18 @@ runs:
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
# Execute the deployment
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Collect the urls from the deployment output
|
||||
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
|
||||
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
|
||||
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
|
||||
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
|
||||
|
||||
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -8,9 +8,6 @@ inputs:
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache instead of installing
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -19,25 +16,8 @@ runs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && inputs.node-modules-cache != 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Restore complete dependency tree
|
||||
if: inputs.node-modules-cache == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.node-modules-cache != 'true'
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
+44
-263
@@ -22,88 +22,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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: Check for complete dependency tree
|
||||
id: dependencies
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: ${{ steps.dependencies.outputs.cache-primary-key }}
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: ${{ steps.dependencies.outputs.cache-primary-key }}
|
||||
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -111,17 +32,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo-e2e
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -134,7 +52,6 @@ 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
|
||||
@@ -142,17 +59,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app-e2e
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -165,7 +79,6 @@ 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
|
||||
@@ -173,10 +86,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -192,38 +103,41 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests against Chromium ──────────────────────────────────
|
||||
e2e-demo:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
- prepare-container-dependencies
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
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:
|
||||
node-modules-cache: true
|
||||
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
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -231,133 +145,36 @@ 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 gallery tests
|
||||
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload gallery blob report
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/gallery/
|
||||
if-no-files-found: warn
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs:
|
||||
- e2e-demo
|
||||
- e2e-app
|
||||
- e2e-gallery
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -367,31 +184,15 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
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: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
@@ -408,11 +209,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(needs.e2e-demo.result == 'failure' ||
|
||||
needs.e2e-app.result == 'failure' ||
|
||||
needs.e2e-gallery.result == 'failure')
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -420,19 +217,3 @@ 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"
|
||||
|
||||
@@ -232,7 +232,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -247,7 +247,6 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -307,7 +306,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -322,7 +321,6 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,22 +42,6 @@ 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(
|
||||
|
||||
@@ -39,18 +39,3 @@ 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"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -225,16 +225,6 @@ 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(
|
||||
|
||||
@@ -177,18 +177,6 @@ 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(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -281,18 +269,6 @@ 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(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -387,14 +387,9 @@ const createAppConfig = ({
|
||||
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createDemoConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
@@ -406,19 +401,9 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
})
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
|
||||
+14
-14
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.10",
|
||||
"@formatjs/intl-displaynames": "7.3.11",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.11",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.12",
|
||||
"@formatjs/intl-pluralrules": "6.3.11",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.11",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -83,8 +83,8 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.3.1",
|
||||
"@tsparticles/preset-links": "4.3.1",
|
||||
"@tsparticles/engine": "4.3.2",
|
||||
"@tsparticles/preset-links": "4.3.2",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.10",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -145,7 +145,7 @@
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.63.0",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
@@ -173,7 +173,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.6.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
|
||||
@@ -58,17 +58,6 @@
|
||||
"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": [
|
||||
@@ -97,11 +86,6 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
const regexp =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// Unanchored regex fragments, shared as the single source of truth for both
|
||||
// the boolean validators below and the HTML `pattern` attribute (the browser
|
||||
// anchors a pattern as `^(?:…)$`).
|
||||
const IPV4 =
|
||||
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
|
||||
|
||||
export const isIPAddress = (input: string): boolean => regexp.test(input);
|
||||
const IPV6 =
|
||||
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
|
||||
|
||||
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
|
||||
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
|
||||
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
|
||||
|
||||
// IPv4 or IPv6 address.
|
||||
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
|
||||
|
||||
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
|
||||
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
|
||||
|
||||
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
|
||||
|
||||
const ipv4Regexp = anchored(IPV4);
|
||||
const ipv6Regexp = anchored(IPV6);
|
||||
|
||||
// IPv4 address, e.g. 192.168.1.10
|
||||
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
|
||||
|
||||
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
|
||||
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
|
||||
|
||||
// IPv4 or IPv6 address
|
||||
export const isIPAddressV4OrV6 = (input: string): boolean =>
|
||||
isIPAddress(input) || isIPv6Address(input);
|
||||
|
||||
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
|
||||
export const isIPNetwork = (input: string): boolean => {
|
||||
const parts = input.split("/");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [address, prefix] = parts;
|
||||
if (!/^\d{1,3}$/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const prefixLength = Number(prefix);
|
||||
if (isIPAddress(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 32;
|
||||
}
|
||||
if (isIPv6Address(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 128;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
@query("ha-input-multi") private _inputMulti?: {
|
||||
reportValidity: () => boolean;
|
||||
};
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this._input?.focus();
|
||||
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (this.selector.text?.multiple) {
|
||||
return true;
|
||||
return this._inputMulti?.reportValidity() ?? true;
|
||||
}
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
|
||||
.inputPrefix=${this.selector.text?.prefix}
|
||||
.helper=${this.helper}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
</ha-input-multi>
|
||||
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
.autoValidate=${this.selector.text?.pattern !== undefined}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@property() public autocomplete?: string;
|
||||
|
||||
/** Regular expression each entry is validated against (HTML `pattern`). */
|
||||
@property() public pattern?: string;
|
||||
|
||||
/** Message shown on an entry when it fails `pattern` validation. */
|
||||
@property({ attribute: "validation-message" })
|
||||
public validationMessage?: string;
|
||||
|
||||
@property({ attribute: "add-label" }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: "remove-label" }) public removeLabel?: string;
|
||||
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
let valid = true;
|
||||
this._inputs?.forEach((input) => {
|
||||
if (!input.reportValidity()) {
|
||||
valid = false;
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
.pattern=${this.pattern}
|
||||
.validationMessage=${this.validationMessage}
|
||||
.autoValidate=${this.pattern !== undefined}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
|
||||
@@ -21,6 +21,21 @@ export interface HttpConfigState {
|
||||
revert_at: string | null;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
@@ -530,6 +530,10 @@ export interface StringSelector {
|
||||
placeholder?: string;
|
||||
autocomplete?: string;
|
||||
multiple?: true;
|
||||
// Regular expression the value must match (HTML `pattern`); with `multiple`
|
||||
// every entry is validated. `validation_message` is shown when it fails.
|
||||
pattern?: string;
|
||||
validation_message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
|
||||
import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
promoteHttpConfig,
|
||||
saveHttpConfig,
|
||||
} from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
const HTTP_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
@@ -57,6 +50,10 @@ export class DialogHttpPendingConfig
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
// names resolve; the dialog re-renders once hass updates.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -114,17 +111,44 @@ export class DialogHttpPendingConfig
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_FIELDS.filter(
|
||||
return HTTP_CONFIG_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
private _formatValue(key: keyof HttpConfig, value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length
|
||||
? value.join(", ")
|
||||
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (key === "ssl_profile") {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.ssl_profile_${value}` as any
|
||||
) || String(value)
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
const { stable, pending } = this._params.state;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
@@ -187,9 +211,25 @@ export class DialogHttpPendingConfig
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
<span class="field">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</span>
|
||||
<span class="values">
|
||||
<span class="old"
|
||||
>${this._formatValue(key, stable[key])}</span
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowRight}
|
||||
style=${styleMap({
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
<span class="new"
|
||||
>${this._formatValue(key, pending![key])}</span
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
@@ -320,13 +360,37 @@ export class DialogHttpPendingConfig
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
color: var(--secondary-text-color);
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
.values .new {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.values ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
} from "../../../common/string/is_ip_address";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -10,7 +14,11 @@ import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaForm } from "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import {
|
||||
fetchHttpConfig,
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -24,10 +32,6 @@ const SCHEMA = memoizeOne(
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
@@ -81,7 +85,15 @@ const SCHEMA = memoizeOne(
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_network"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -108,6 +120,18 @@ const SCHEMA = memoizeOne(
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "server_host",
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_host"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
@@ -165,6 +189,9 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
const portChanged =
|
||||
!!this._stable && this._config?.server_port !== this._stable.server_port;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -174,6 +201,17 @@ class HaConfigHttpForm extends LitElement {
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
portChanged
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.port_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -318,15 +356,7 @@ class HaConfigHttpForm extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
this._handleSaveError(err);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
@@ -340,6 +370,34 @@ class HaConfigHttpForm extends LitElement {
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
private _handleSaveError(err: any): void {
|
||||
const rawMessage =
|
||||
(typeof err === "string" ? err : err?.message) ||
|
||||
this.hass.localize("ui.panel.config.network.http.save_error");
|
||||
// Voluptuous formats validation errors as
|
||||
// "<reason> @ data['config']['<field>'][<index>]. Got '<value>'"
|
||||
// Strip the internal data path for display and pick the deepest known
|
||||
// field name so it can also be flagged inline.
|
||||
const message =
|
||||
rawMessage.replace(/\s*@\s*data(\['[^']*'\]|\[\d+\])+/g, "").trim() ||
|
||||
rawMessage;
|
||||
const field = [...rawMessage.matchAll(/\['([^']+)'\]/g)]
|
||||
.map((match) => match[1])
|
||||
.reverse()
|
||||
.find((name) => HTTP_CONFIG_FIELDS.includes(name as keyof HttpConfig)) as
|
||||
keyof HttpConfig | undefined;
|
||||
|
||||
if (field) {
|
||||
// Show a card-level alert too — the field may sit in a collapsed section.
|
||||
this._error = `${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${field}` as any
|
||||
)}: ${message}`;
|
||||
this._fieldErrors = { [field]: message };
|
||||
} else {
|
||||
this._error = message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -1486,6 +1486,7 @@
|
||||
"auto_revert": "Settings will automatically revert in {time}.",
|
||||
"reverted": "Home Assistant reverted the HTTP server settings.",
|
||||
"changes_label": "Changed settings:",
|
||||
"not_set": "Not set",
|
||||
"confirm": "Confirm",
|
||||
"revert": "Revert",
|
||||
"close": "Close",
|
||||
@@ -8679,6 +8680,10 @@
|
||||
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
|
||||
"save": "Save",
|
||||
"save_no_changes": "Nothing changed — no restart needed.",
|
||||
"save_error": "Could not save the HTTP configuration.",
|
||||
"port_warning": "Changing the server port breaks the connection for the Home Assistant mobile apps and other clients until you update the port in their settings.",
|
||||
"invalid_host": "Enter a valid IP address.",
|
||||
"invalid_network": "Enter a valid IP address or network.",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
@@ -8690,7 +8695,7 @@
|
||||
"ssl": "SSL/TLS",
|
||||
"reverse_proxy": "Reverse proxy",
|
||||
"ip_banning": "IP banning",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "More options"
|
||||
},
|
||||
"fields": {
|
||||
"server_port": "Server port",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
isIPAddress,
|
||||
isIPAddressV4OrV6,
|
||||
isIPNetwork,
|
||||
isIPv6Address,
|
||||
} from "../../../src/common/string/is_ip_address";
|
||||
|
||||
describe("isIPAddress (IPv4)", () => {
|
||||
it("accepts valid IPv4 addresses", () => {
|
||||
expect(isIPAddress("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddress("0.0.0.0")).toBe(true);
|
||||
expect(isIPAddress("255.255.255.255")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv4 addresses", () => {
|
||||
expect(isIPAddress("256.1.1.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1.10/24")).toBe(false);
|
||||
expect(isIPAddress("fe80::1")).toBe(false);
|
||||
expect(isIPAddress("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPv6Address", () => {
|
||||
it("accepts valid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("fe80::85d:e82c:9446:7995")).toBe(true);
|
||||
expect(isIPv6Address("::1")).toBe(true);
|
||||
expect(isIPv6Address("1050:0000:0000:0000:0005:0600:300c:326b")).toBe(true);
|
||||
expect(isIPv6Address("::ffff:192.168.1.1")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("192.168.1.10")).toBe(false);
|
||||
expect(isIPv6Address("fe80::85d::7995")).toBe(false);
|
||||
expect(isIPv6Address("gggg::1")).toBe(false);
|
||||
expect(isIPv6Address("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPAddressV4OrV6", () => {
|
||||
it("accepts both families", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddressV4OrV6("fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.0/24")).toBe(false);
|
||||
expect(isIPAddressV4OrV6("not-an-ip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPNetwork (CIDR)", () => {
|
||||
it("accepts valid networks", () => {
|
||||
expect(isIPNetwork("192.168.1.0/24")).toBe(true);
|
||||
expect(isIPNetwork("10.0.0.0/8")).toBe(true);
|
||||
expect(isIPNetwork("172.16.0.0/12")).toBe(true);
|
||||
expect(isIPNetwork("0.0.0.0/0")).toBe(true);
|
||||
expect(isIPNetwork("fd00::/8")).toBe(true);
|
||||
expect(isIPNetwork("fe80::/128")).toBe(true);
|
||||
});
|
||||
it("rejects invalid networks", () => {
|
||||
// Prefix out of range — the reported production error.
|
||||
expect(isIPNetwork("172.30.33.0/24444444")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/33")).toBe(false);
|
||||
expect(isIPNetwork("fd00::/129")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/24/24")).toBe(false);
|
||||
expect(isIPNetwork("not-a-network/24")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The HTML `pattern` attribute is anchored by the browser as `^(?:…)$`.
|
||||
const matchesPattern = (pattern: string, value: string): boolean =>
|
||||
new RegExp(`^(?:${pattern})$`).test(value);
|
||||
|
||||
describe("IP_ADDRESS_PATTERN", () => {
|
||||
it("accepts IPv4 and IPv6 addresses", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.10")).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.0/24")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "not-an-ip")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "256.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IP_ADDRESS_OR_NETWORK_PATTERN", () => {
|
||||
it("accepts addresses and networks", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.10")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/24")
|
||||
).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "fd00::/8")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
it("rejects out-of-range prefixes and garbage", () => {
|
||||
// The reported production error.
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "172.30.33.0/24444444")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/33")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "1.1.1.1/233444")
|
||||
).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "not-an-ip")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
+462
-68
@@ -4,29 +4,164 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:app
|
||||
*/
|
||||
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";
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell
|
||||
@@ -34,40 +169,60 @@ import {
|
||||
|
||||
test.describe("App shell", () => {
|
||||
test("page loads and ha-test element mounts", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
expectNoPageErrors(errors, undefined, []);
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders with expected panels", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
// 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();
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const historyLink = await ensureAppSidebarPanelVisible(page, "history");
|
||||
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 });
|
||||
await historyLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_TIMEOUT });
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: SHELL_TIMEOUT });
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
@@ -76,29 +231,34 @@ test.describe("App shell", () => {
|
||||
test("sidebar renders notification badge", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
|
||||
const sidebar = appSidebar(page);
|
||||
await expect(sidebar).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const notificationsLink = sidebar.locator("#sidebar-notifications");
|
||||
await expect(notificationsLink).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(notificationsLink).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(notificationsLink.locator(".badge").first()).toHaveText("1", {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar marks the active panel as selected", async ({ page }) => {
|
||||
const lovelaceLink = appSidebarPanel(page, "lovelace");
|
||||
const historyLink = appSidebarPanel(page, "history");
|
||||
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");
|
||||
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(lovelaceLink).toHaveClass(/selected/, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await expect(historyLink).not.toHaveClass(/selected/);
|
||||
|
||||
await goToPanel(page, "/history");
|
||||
await expect(historyLink).toHaveClass(/selected/, {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
await expect(lovelaceLink).not.toHaveClass(/selected/);
|
||||
});
|
||||
@@ -111,16 +271,124 @@ test.describe("App shell", () => {
|
||||
await goToPanel(page, "/?scenario=non-admin#/lovelace");
|
||||
|
||||
// Wait for the sidebar to mount before asserting on its contents.
|
||||
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(
|
||||
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
|
||||
).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// Config panel is adminOnly — should not appear for non-admin.
|
||||
await expect(appSidebarConfig(page)).not.toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
defineRouteSmokeTests(appRouteSmokeGroups);
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
@@ -149,7 +417,7 @@ test.describe("Lovelace dashboard", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Light more-info dialog", () => {
|
||||
for (const { view, element, content } of moreInfoViewElements) {
|
||||
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
|
||||
test(`opens more-info ${view} view for a light entity`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -187,7 +455,16 @@ test.describe("Light more-info dialog", () => {
|
||||
|
||||
// Each view should render its own characteristic content, not just an
|
||||
// empty shell.
|
||||
await assertElementContent(dialog, content);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -242,27 +519,144 @@ 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 = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/config");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, ha-config-dashboard").first()
|
||||
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
|
||||
|
||||
expectNoPageErrors(errors);
|
||||
// Filter known pre-existing errors from vendor code
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
const getDashboard = async (page) => {
|
||||
test("dashboard renders key settings links", async ({ page }) => {
|
||||
await goToPanel(page, "/config");
|
||||
const dashboard = page.locator("ha-config-dashboard");
|
||||
await expect(dashboard).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
return dashboard;
|
||||
};
|
||||
|
||||
defineLinkSmokeTests(
|
||||
"config links point to expected pages",
|
||||
configLinks,
|
||||
getDashboard
|
||||
);
|
||||
const dashboard = page.locator("ha-config-dashboard");
|
||||
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -5,29 +5,6 @@
|
||||
// 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 });
|
||||
@@ -35,8 +12,10 @@ mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const suite of ["demo", "app", "gallery"]) {
|
||||
const src = `test/e2e/reports/${suite}`;
|
||||
const files = findBlobReports(src);
|
||||
if (!files?.length) {
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
|
||||
} catch {
|
||||
// Suite report directory doesn't exist (e.g. job was skipped or failed
|
||||
// before uploading). Skip gracefully.
|
||||
process.stderr.write(
|
||||
@@ -45,7 +24,6 @@ for (const suite of ["demo", "app", "gallery"]) {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
const name = relative(src, file).replace(/[\\/]/g, "-");
|
||||
cpSync(file, join(dest, `${suite}-${name}`));
|
||||
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-34
@@ -1,68 +1,146 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
NAVIGATION_TIMEOUT,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
appErrors as filterAppErrors,
|
||||
} from "./helpers";
|
||||
import {
|
||||
activateDemoSidebarPanel,
|
||||
demoCardSelector,
|
||||
moreInfoCardSelector,
|
||||
openDemoSidebar,
|
||||
waitForDemoReady,
|
||||
} from "./demo/helpers";
|
||||
|
||||
test.describe("Home Assistant Demo", () => {
|
||||
let pageErrors: ReturnType<typeof trackPageErrors>;
|
||||
// Collect JS errors during each test so we can assert no unexpected crashes.
|
||||
let pageErrors: Error[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = trackPageErrors(page);
|
||||
pageErrors = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err));
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
function appErrors() {
|
||||
return filterAppErrors(pageErrors);
|
||||
}
|
||||
|
||||
expectNoPageErrors(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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||
|
||||
await expect(page.locator(demoCardSelector).first()).toBeVisible({
|
||||
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,
|
||||
});
|
||||
|
||||
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({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
await openDemoSidebar(page);
|
||||
await activateDemoSidebarPanel(page, "map");
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
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({
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await page.locator(moreInfoCardSelector).first().click();
|
||||
|
||||
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,
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
// 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 });
|
||||
|
||||
const title = dialog.locator("span.title");
|
||||
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
+306
-61
@@ -7,117 +7,362 @@
|
||||
* Run with:
|
||||
* yarn test:e2e:gallery
|
||||
*/
|
||||
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";
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Gallery shell", () => {
|
||||
test("page loads and ha-gallery mounts", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToGalleryHome(page);
|
||||
await page.goto("/");
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
|
||||
expectNoPageErrors(errors, undefined, GALLERY_SHELL_IGNORED_PAGE_ERRORS);
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders navigation links", async ({ page }) => {
|
||||
await goToGalleryHome(page);
|
||||
await expect(galleryLocator(page, "ha-drawer")).toBeAttached({
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("ha-gallery", { state: "attached" });
|
||||
// The gallery drawer sidebar is inside ha-gallery's shadow root
|
||||
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
defineGallerySmokeTests("Components", "components", componentPages);
|
||||
defineGallerySmokeTests("More-info dialogs", "more-info", moreInfoPages);
|
||||
defineGallerySmokeTests("Lovelace cards", "lovelace", lovelacePages);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const componentPages: { name: string; selector: string }[] = [
|
||||
{ name: "ha-alert", selector: "demo-components-ha-alert" },
|
||||
{ name: "ha-badge", selector: "demo-components-ha-badge" },
|
||||
{ name: "ha-bar", selector: "demo-components-ha-bar" },
|
||||
{ name: "ha-button", selector: "demo-components-ha-button" },
|
||||
{ name: "ha-chips", selector: "demo-components-ha-chips" },
|
||||
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
|
||||
{
|
||||
name: "ha-control-circular-slider",
|
||||
selector: "demo-components-ha-control-circular-slider",
|
||||
},
|
||||
{
|
||||
name: "ha-control-number-buttons",
|
||||
selector: "demo-components-ha-control-number-buttons",
|
||||
},
|
||||
{
|
||||
name: "ha-control-select-menu",
|
||||
selector: "demo-components-ha-control-select-menu",
|
||||
},
|
||||
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
|
||||
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
|
||||
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
|
||||
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
|
||||
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
|
||||
{
|
||||
name: "ha-expansion-panel",
|
||||
selector: "demo-components-ha-expansion-panel",
|
||||
},
|
||||
{ name: "ha-faded", selector: "demo-components-ha-faded" },
|
||||
{ name: "ha-form", selector: "demo-components-ha-form" },
|
||||
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
|
||||
{
|
||||
name: "ha-hs-color-picker",
|
||||
selector: "demo-components-ha-hs-color-picker",
|
||||
},
|
||||
{ name: "ha-input", selector: "demo-components-ha-input" },
|
||||
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
|
||||
{ name: "ha-list", selector: "demo-components-ha-list" },
|
||||
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
|
||||
{
|
||||
name: "ha-progress-button",
|
||||
selector: "demo-components-ha-progress-button",
|
||||
},
|
||||
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
|
||||
{ name: "ha-selector", selector: "demo-components-ha-selector" },
|
||||
{ name: "ha-slider", selector: "demo-components-ha-slider" },
|
||||
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
|
||||
{ name: "ha-switch", selector: "demo-components-ha-switch" },
|
||||
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
|
||||
{ name: "ha-tip", selector: "demo-components-ha-tip" },
|
||||
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
|
||||
{
|
||||
name: "ha-adaptive-dialog",
|
||||
selector: "demo-components-ha-adaptive-dialog",
|
||||
},
|
||||
{
|
||||
name: "ha-adaptive-popover",
|
||||
selector: "demo-components-ha-adaptive-popover",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Components", () => {
|
||||
for (const { name, selector } of componentPages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `components/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const moreInfoPages: { name: string; selector: string }[] = [
|
||||
{ name: "light", selector: "demo-more-info-light" },
|
||||
{ name: "climate", selector: "demo-more-info-climate" },
|
||||
{ name: "cover", selector: "demo-more-info-cover" },
|
||||
{ name: "fan", selector: "demo-more-info-fan" },
|
||||
{ name: "humidifier", selector: "demo-more-info-humidifier" },
|
||||
{ name: "input-number", selector: "demo-more-info-input-number" },
|
||||
{ name: "input-text", selector: "demo-more-info-input-text" },
|
||||
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
|
||||
{ name: "lock", selector: "demo-more-info-lock" },
|
||||
{ name: "media-player", selector: "demo-more-info-media-player" },
|
||||
{ name: "number", selector: "demo-more-info-number" },
|
||||
{ name: "scene", selector: "demo-more-info-scene" },
|
||||
{ name: "timer", selector: "demo-more-info-timer" },
|
||||
{ name: "update", selector: "demo-more-info-update" },
|
||||
{ name: "vacuum", selector: "demo-more-info-vacuum" },
|
||||
{ name: "water-heater", selector: "demo-more-info-water-heater" },
|
||||
];
|
||||
|
||||
test.describe("More-info dialogs", () => {
|
||||
for (const { name, selector } of moreInfoPages) {
|
||||
test(`more-info ${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `more-info/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace card pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lovelacePages: { name: string; selector: string }[] = [
|
||||
{ name: "area-card", selector: "demo-lovelace-area-card" },
|
||||
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
|
||||
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
|
||||
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
|
||||
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
|
||||
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
|
||||
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
|
||||
{
|
||||
name: "grid-and-stack-card",
|
||||
selector: "demo-lovelace-grid-and-stack-card",
|
||||
},
|
||||
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
|
||||
{ name: "light-card", selector: "demo-lovelace-light-card" },
|
||||
{ name: "map-card", selector: "demo-lovelace-map-card" },
|
||||
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
|
||||
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
|
||||
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
|
||||
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
|
||||
{
|
||||
name: "picture-elements-card",
|
||||
selector: "demo-lovelace-picture-elements-card",
|
||||
},
|
||||
{
|
||||
name: "picture-entity-card",
|
||||
selector: "demo-lovelace-picture-entity-card",
|
||||
},
|
||||
{
|
||||
name: "picture-glance-card",
|
||||
selector: "demo-lovelace-picture-glance-card",
|
||||
},
|
||||
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
|
||||
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
|
||||
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
|
||||
];
|
||||
|
||||
test.describe("Lovelace cards", () => {
|
||||
for (const { name, selector } of lovelacePages) {
|
||||
test(`${name} renders without errors`, async ({ page }) => {
|
||||
await assertPageLoads(page, `lovelace/${name}`, selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific interaction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Component interactions", () => {
|
||||
test("ha-alert renders all four types", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-alert");
|
||||
await goToGalleryPage(page, "components/ha-alert");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// The demo uses property binding (.alertType) not attribute binding, so we
|
||||
// verify that multiple ha-alert elements are present.
|
||||
// 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.nth(3)).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
// There should be at least 4 alerts (one per type)
|
||||
await expect(alerts)
|
||||
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
|
||||
.catch(async () => {
|
||||
// If not exactly 4, just verify there are some (demo may include more)
|
||||
const count = await alerts.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-button renders primary action button", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-button");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-button, mwc-button");
|
||||
await goToGalleryPage(page, "components/ha-button");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-control-slider");
|
||||
|
||||
await expectGalleryDemoElement(demo, "ha-control-slider");
|
||||
await goToGalleryPage(page, "components/ha-control-slider");
|
||||
const demo = page.locator(
|
||||
"ha-gallery >> demo-components-ha-control-slider"
|
||||
);
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-form renders schema-driven fields", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-form");
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
await expectGalleryDemoElement(demo, "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 });
|
||||
});
|
||||
|
||||
test("tile-card renders entity state", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "lovelace/tile-card");
|
||||
|
||||
await expectGalleryDemoElement(demo, "hui-tile-card");
|
||||
await goToGalleryPage(page, "lovelace/tile-card");
|
||||
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("more-info light renders controls", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "more-info/light");
|
||||
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 });
|
||||
});
|
||||
|
||||
await expectGalleryDemoElement(
|
||||
demo,
|
||||
"ha-control-slider, ha-more-info-light, more-info-content",
|
||||
SHELL_TIMEOUT
|
||||
);
|
||||
test("more-info cover renders position controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/cover");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-cover");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("ha-gauge renders a gauge element", async ({ page }) => {
|
||||
await 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({
|
||||
await goToGalleryPage(page, "components/ha-gauge");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-switch toggles state on click", async ({ page }) => {
|
||||
const demo = await getGalleryDemo(page, "components/ha-switch");
|
||||
await goToGalleryPage(page, "components/ha-switch");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
// Find the first interactive (non-disabled) switch. Pull its checked state
|
||||
// from the property because ha-switch toggles via property, not attribute.
|
||||
// from the property — ha-switch toggles via property, not the attribute.
|
||||
const switchEl = demo.locator("ha-switch:not([disabled])").first();
|
||||
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
|
||||
|
||||
const before = await switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
);
|
||||
const before = await switchEl.evaluate((el: any) => el.checked === true);
|
||||
await switchEl.click();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
switchEl.evaluate(
|
||||
(el: HTMLElement & { checked?: boolean }) => el.checked === true
|
||||
),
|
||||
{ timeout: QUICK_TIMEOUT }
|
||||
)
|
||||
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
|
||||
timeout: QUICK_TIMEOUT,
|
||||
})
|
||||
.toBe(!before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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" },
|
||||
];
|
||||
+17
-64
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
@@ -18,67 +17,21 @@ export const NAVIGATION_TIMEOUT = 30_000;
|
||||
|
||||
// ── Error filtering ─────────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { getE2EWorkers } from "./playwright-workers";
|
||||
|
||||
const APP_PORT = 8095;
|
||||
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
|
||||
@@ -12,10 +11,8 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results/app",
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
|
||||
|
||||
use: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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`
|
||||
@@ -17,10 +16,8 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results/demo",
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
|
||||
|
||||
use: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { getE2EWorkers } from "./playwright-workers";
|
||||
|
||||
const GALLERY_PORT = 8100;
|
||||
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
|
||||
@@ -12,10 +11,8 @@ export default defineConfig({
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
fullyParallel: true,
|
||||
workers: getE2EWorkers(),
|
||||
|
||||
outputDir: "test-results/gallery",
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
|
||||
|
||||
use: {
|
||||
|
||||
+26
-156
@@ -1,106 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs each e2e suite (demo, app, gallery) regardless of individual failures,
|
||||
// then collects and merges blob reports locally and exits with a non-zero code
|
||||
// if any suite failed.
|
||||
// then collects and merges blob reports and exits with a non-zero code if any
|
||||
// suite failed.
|
||||
//
|
||||
// Usage: node test/e2e/run-suites.mjs <suite> [<suite> ...]
|
||||
// Where <suite> matches a test:e2e:<suite> script in package.json,
|
||||
// e.g. "demo", "app", "gallery".
|
||||
//
|
||||
// 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.
|
||||
// Using ; or running suites independently avoids the && short-circuit problem
|
||||
// where a failing suite skips the remaining suites and their blob reports.
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
import { execFileSync } from "child_process";
|
||||
|
||||
const suites = process.argv.slice(2);
|
||||
if (!suites.length) {
|
||||
@@ -108,72 +18,32 @@ if (!suites.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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;
|
||||
const failures = [];
|
||||
|
||||
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) {
|
||||
process.stdout.write(`\n--- Running suite: test:e2e:${suite} ---\n`);
|
||||
try {
|
||||
execFileSync("yarn", [`test:e2e:${suite}`], { stdio: "inherit" });
|
||||
} catch {
|
||||
failures.push(suite);
|
||||
}
|
||||
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.
|
||||
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" }
|
||||
);
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -2745,135 +2745,135 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/bigdecimal@npm:0.2.6":
|
||||
version: 0.2.6
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.6"
|
||||
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
|
||||
"@formatjs/bigdecimal@npm:0.2.7":
|
||||
version: 0.2.7
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.7"
|
||||
checksum: 10/e78fe804ed6805708b849fbd49006e981cfa736677bac29556f84bd6ca5271f848076f2f35de087197a559d434435bbfaecca2776563a5b46ecd6246bde7406d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/fast-memoize@npm:3.1.6":
|
||||
version: 3.1.6
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.6"
|
||||
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
|
||||
"@formatjs/fast-memoize@npm:3.1.7":
|
||||
version: 3.1.7
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.7"
|
||||
checksum: 10/2d7ead48684539764ecb8a52178719169186595e830d9d6941eff12bec1ae31e20642a9cfd197007c48a8cc7e965bf61445ffcccc0fd77281341271d00e7b8fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.13":
|
||||
version: 3.5.13
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.13"
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.14":
|
||||
version: 3.5.14
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.14"
|
||||
dependencies:
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
|
||||
checksum: 10/b6451febdcfbe32571af5ae1c94cc560f6b3d815401321849c9dba229c460edf9ee4eca4c2d4da6dedefea416f2e3b10b4aa5507383e12b67042f941157e8045
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
|
||||
checksum: 10/e842d8ec0c17c174da0eb562e161f5216b31cb83546bf94fd3dd6e4b6493f8c0c404b9fb47b6b8106a607a83d305386bc1fb44abbc70453cf467d261e39d09c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.10":
|
||||
version: 2.1.10
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
|
||||
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.11":
|
||||
version: 2.1.11
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
|
||||
checksum: 10/492f42bd4ad72b2cdb4fd75a07e3874aa78e88f346027058f74c58e3828378092784176c9f7c77f4b5baf58ffaa1a4e8f5efcb53143552def2990510d0ac3c16
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-datetimeformat@npm:7.4.10":
|
||||
version: 7.4.10
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.4.10"
|
||||
"@formatjs/intl-datetimeformat@npm:7.5.0":
|
||||
version: 7.5.0
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.5.0"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/67fd55857555fe2651bc8ed5544fb036807d926542c92ccc07e475c14493d93a87d2cc805d04c812c6df68c9fdaeb4d64b399ac019f8c27940e2b872c9837618
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/8f5bbcd2768609e466e5d03cd42880e62fa5c584ef57d716023a0f89ffff12036da41a248956cc1b3369b1af0155459464ac05d5f8fdfb42c0aa066d77ede815
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-displaynames@npm:7.3.11":
|
||||
version: 7.3.11
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.11"
|
||||
"@formatjs/intl-displaynames@npm:7.3.12":
|
||||
version: 7.3.12
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/3278e430d7f2bbb0656f5cf0a76a1a539a572c58f0d2473e2a175bc927de7e3a4a01823c3c98a38dc2878fd05ca3a6ebe83afa94dbd254733fe9195377064347
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/d802f9d44831fcb567efd7399b2f20da7872bf6cdf3cc8c5b38b188111fc8c38af704a79f55902caadd50edb8ebf2f830c473aecc5a93573ddbe1bcfba275793
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-durationformat@npm:0.10.16":
|
||||
version: 0.10.16
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.16"
|
||||
"@formatjs/intl-durationformat@npm:0.10.17":
|
||||
version: 0.10.17
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.17"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/8d0f5bbf24528c6d8e65636502de64b13e12c71854935db8bf2ce6bcc5b6e0e19533d0e0178c1079b1dd52214b025369c98e297b0e8484fa8ce29c9f6408c953
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/ee3210da7b946a80995b36609a360a55d63f4626e3f40223215d782082f1f8254c49af6145f397ea6324b370038095145c6ff51fda8712e7b611f8609e1ac2b3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
|
||||
version: 3.2.10
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
|
||||
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.11":
|
||||
version: 3.2.11
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.11"
|
||||
checksum: 10/3132dba96c7cfe63787e126a91e620211a32fa6a623cb763f96102ff153845fb12486f378aa1d761736a7641adb19f5cc307156c8b7a40eedd00223b87c8d2a9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-listformat@npm:8.3.11":
|
||||
version: 8.3.11
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.11"
|
||||
"@formatjs/intl-listformat@npm:8.3.12":
|
||||
version: 8.3.12
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/a08a74c22bb01871944cad91fcb87342e30f3f6792375af3028737794a56b2dec6b7506c8e2b9d0495f4eff50f4d63542fcf86116cd4fdf1d4d8b6838a54ac5c
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/3ae640ab968b3051fd1b2d150d892a087d5a4feecb8bdf4a6a61726888996ee0b054de43e35548374d0a61d8577b5a48f23abfb9506c1560f88d64c0809691f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-locale@npm:5.3.9":
|
||||
version: 5.3.9
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.9"
|
||||
"@formatjs/intl-locale@npm:5.3.10":
|
||||
version: 5.3.10
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.10"
|
||||
dependencies:
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
|
||||
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.9"
|
||||
checksum: 10/f7d8be5ea145089b9daa56e24158f835ed0f3d67d3230b42c3a8a32f3dbdd98534236e9262d86f628288d09968fec32d1c2f5906ec1d5f38e692d436a4052255
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-localematcher@npm:0.8.11":
|
||||
version: 0.8.11
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.11"
|
||||
"@formatjs/intl-localematcher@npm:0.8.12":
|
||||
version: 0.8.12
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.12"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/482b819100997439b2c7295c8112b43c81f64083644cde7124ef1afecaa8fc663bc7b9820b6386d02b45872db7a943c3f1f43a4f78780fe52ed4873ef422e7dc
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/4f9ecb936ac76cb978460edacbb2704e9760f65203e2c17f96b8db605a3c344ecc7da2ac78c897316f519b033c623b39062ba720e23fe0c2162af7c761f765e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-numberformat@npm:9.3.12":
|
||||
version: 9.3.12
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.12"
|
||||
"@formatjs/intl-numberformat@npm:9.3.13":
|
||||
version: 9.3.13
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.13"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/6a9f3115136ce9f74fab520d5eeddb6a3f23d32492b004c9c6f108a19dd18e9ccaec04582569238f089b3028cc68783a2b75328b3ce38b13bf30d6267cb33b20
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/59530d3aec0411a2dae8f2113829b969dceec3ec4a59032cc985ef1b9cebec6d406fbe4dd44ac69e905b4f3b3edb333d61f728a5610fc1e1e132e4bdcb2436aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-pluralrules@npm:6.3.11":
|
||||
version: 6.3.11
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.11"
|
||||
"@formatjs/intl-pluralrules@npm:6.3.12":
|
||||
version: 6.3.12
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.12"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/c5506c1a20f1aa7fdca24b6800243aec5cbacd5aa0de96d01b497fa9485f6affa7abf6b0d2ea04ebff0ec8a2d22a41913f4353b9fd9c6054899a9f168ef2ebd0
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/23833b17822a648fc4e87dfe581733f303a8d1c4721a47e875994bcb56c5985c83ea01f0ab176557886fc6b2ef158ce50f9cafa1e1441badefddec0ea79c5303
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.11":
|
||||
version: 12.3.11
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.11"
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.12":
|
||||
version: 12.3.12
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/ead23de113c9c28334ff2f8696e0f5ff0567f2b929def10d06e67dba2ca502967671abb1bb6ad06b200329e0215b469edd3c3ad9ed4259b313e97f2badfdab53
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/c667ed62a13cbc8cd5d07f5dbc57a4d1b7e23c31f2aac0ef9e5f509feec014c5a2dfd77ac41a2c34fe537d6f1f6d8ebc8391a479a4294867bb4e980c7211f822
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
|
||||
version: 2.3.8
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.9":
|
||||
version: 2.3.9
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.9"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/36254064dd8aab3cb2cbe31db88d751cb0c1d2b0739563ddd1736ed7bd8ec02881de385a69cc4ab7b691f670a3f11ec61a41ba678baa3d0967d87dc0b395cc7b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -2984,73 +2984,73 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/core@npm:^0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/core@npm:0.63.0"
|
||||
"@html-eslint/core@npm:^0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/core@npm:0.64.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.63.0"
|
||||
"@html-eslint/types": "npm:^0.64.0"
|
||||
html-standard: "npm:^0.0.13"
|
||||
checksum: 10/3fb125fa0bd7c70d9255b465c57328d01f604d201e9079ddafc5a7793b618f59d0ffe77a2aa948e4a07ac9e6f1790aea94d87c2ea266f8b91f3992a1b617e024
|
||||
checksum: 10/682d735353acd711ee74e681b5949a35f650e425be670509e1ddca5beeb708347b9c3a740b88eb13d19f6cbf54a903e4dcc173988f6c1c6c9fd212b997df6f17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/eslint-plugin@npm:0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/eslint-plugin@npm:0.63.0"
|
||||
"@html-eslint/eslint-plugin@npm:0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/eslint-plugin@npm:0.64.0"
|
||||
dependencies:
|
||||
"@eslint/plugin-kit": "npm:^0.4.1"
|
||||
"@html-eslint/core": "npm:^0.63.0"
|
||||
"@html-eslint/parser": "npm:^0.63.0"
|
||||
"@html-eslint/template-parser": "npm:^0.63.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.63.0"
|
||||
"@html-eslint/types": "npm:^0.63.0"
|
||||
"@html-eslint/core": "npm:^0.64.0"
|
||||
"@html-eslint/parser": "npm:^0.64.0"
|
||||
"@html-eslint/template-parser": "npm:^0.64.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.64.0"
|
||||
"@html-eslint/types": "npm:^0.64.0"
|
||||
"@rviscomi/capo.js": "npm:^2.1.0"
|
||||
html-standard: "npm:^0.0.13"
|
||||
peerDependencies:
|
||||
eslint: ">=8.0.0 || ^10.0.0-0"
|
||||
checksum: 10/ba729511122ce5d20c2dca4300b88ed7ad2afae9c7c7511a9de9e0abc75cea10a22ca7943604373b486d7ac584f6624de056005f9868799c43ce88949a5b33c2
|
||||
checksum: 10/3badf5dab3fd62be5f28ca5703004271224a684273ba419cadd7712b47fd57724f6b5ec5e3081e47c3e700fe6ae4066cd71bbde0041d2cb70b3e784a11949eec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/parser@npm:^0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/parser@npm:0.63.0"
|
||||
"@html-eslint/parser@npm:^0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/parser@npm:0.64.0"
|
||||
dependencies:
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.63.0"
|
||||
"@html-eslint/types": "npm:^0.63.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.64.0"
|
||||
"@html-eslint/types": "npm:^0.64.0"
|
||||
css-tree: "npm:^3.1.0"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/aae7029e9e5e44e04223a5df29878c229d073b336284c81ad74bbd4ce97a80b10b4bc7d4c1d8c32a3225e7ccf13cc77d73d4197e28bb006e2ff15eed28e266cf
|
||||
checksum: 10/7ce0205a07cbddf63959ad2bcf39cf8c4252c5a88abce20326c5b02fd13cd0d81ec6ebe569c4c57b4f9a27842ae62592ad5e47bca58d00cb91d413ad1dad98d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/template-parser@npm:^0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/template-parser@npm:0.63.0"
|
||||
"@html-eslint/template-parser@npm:^0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/template-parser@npm:0.64.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.63.0"
|
||||
"@html-eslint/types": "npm:^0.64.0"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/64409e5b3efcb6ba1a2a001ee1cca3308bc0f99db1ed83fbc8c4f7854d4971f41219f664f3664fbc6e41b116f5ac9d27c8e5145a258250c1e1d5ecb0961f2f82
|
||||
checksum: 10/2dcdc87c2355af6f45814217a812d54acbab7eca6edb6c0cf7ef0fbafca5a7c75c03b6f4acb2dd4f843abb2e34dae2720cd623f0ec0ccfd4570961b025f461a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/template-syntax-parser@npm:^0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/template-syntax-parser@npm:0.63.0"
|
||||
"@html-eslint/template-syntax-parser@npm:^0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/template-syntax-parser@npm:0.64.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.63.0"
|
||||
checksum: 10/2e0f21172bfc3762df16443a81a181d59bdef65f00eb6e2a944703cf862b994193053803609cbf3c55fc98d01a3fb82bb800d6d8c7fdbf69a6dad7a55cabf7ab
|
||||
"@html-eslint/types": "npm:^0.64.0"
|
||||
checksum: 10/774d319f8485cbd20b351767c693e1e281d8e3b7d8c6bf01a2db407684c0656d8789535df0f2357d9bc0df2299023d2d1045a425b72dea1144d265340cfa9031
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/types@npm:^0.63.0":
|
||||
version: 0.63.0
|
||||
resolution: "@html-eslint/types@npm:0.63.0"
|
||||
"@html-eslint/types@npm:^0.64.0":
|
||||
version: 0.64.0
|
||||
resolution: "@html-eslint/types@npm:0.64.0"
|
||||
dependencies:
|
||||
"@types/css-tree": "npm:^2.3.11"
|
||||
"@types/estree": "npm:^1.0.6"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/892d3cde8e43546e480d327c9d9273f2590a40103616f18a6458770150b49d87e3ba44f2ded68223569a0d04fab61c693277c1a9be3f15ab69c8dabe1e302455
|
||||
checksum: 10/9813e675f06fb950911a0b51e113ff05b3eabe73e6396a3ac2afeeab557c7608d883d092f40fd77f843f362f6cbfefa3cd45df3e0135ce71b5f9dd281eef0caa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5214,174 +5214,174 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/animation-utils@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/animation-utils@npm:4.3.1"
|
||||
"@tsparticles/animation-utils@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/animation-utils@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/ca245b8ce44cd3dee96b99d8d88912c925c71b7d9286cc984087352c6cbc1c73cbd1b892eac4b22dbc343fd829bd11bd8f4c3438295b7e0036cb9c42484fb64f
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/417b8466a101a14c6c637bd35930fa3065b99b372aef4500d1982a058229492f8ab914336c9f460779907e3b8e24e323b01b4f938b5aa73551ec41943eb408ca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/basic@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/basic@npm:4.3.1"
|
||||
"@tsparticles/basic@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/basic@npm:4.3.2"
|
||||
dependencies:
|
||||
"@tsparticles/engine": "npm:4.3.1"
|
||||
"@tsparticles/plugin-blend": "npm:4.3.1"
|
||||
"@tsparticles/plugin-hex-color": "npm:4.3.1"
|
||||
"@tsparticles/plugin-hsl-color": "npm:4.3.1"
|
||||
"@tsparticles/plugin-move": "npm:4.3.1"
|
||||
"@tsparticles/plugin-rgb-color": "npm:4.3.1"
|
||||
"@tsparticles/shape-circle": "npm:4.3.1"
|
||||
"@tsparticles/updater-opacity": "npm:4.3.1"
|
||||
"@tsparticles/updater-out-modes": "npm:4.3.1"
|
||||
"@tsparticles/updater-paint": "npm:4.3.1"
|
||||
"@tsparticles/updater-size": "npm:4.3.1"
|
||||
checksum: 10/d4e4011e3601c6f281283f76f7a8fbd0b1f449d604bfc10047a7809dc5f03351e726d2ec700ca804d249b689a950ca4a8aee93e724bdb25d7d28faf561898f82
|
||||
"@tsparticles/engine": "npm:4.3.2"
|
||||
"@tsparticles/plugin-blend": "npm:4.3.2"
|
||||
"@tsparticles/plugin-hex-color": "npm:4.3.2"
|
||||
"@tsparticles/plugin-hsl-color": "npm:4.3.2"
|
||||
"@tsparticles/plugin-move": "npm:4.3.2"
|
||||
"@tsparticles/plugin-rgb-color": "npm:4.3.2"
|
||||
"@tsparticles/shape-circle": "npm:4.3.2"
|
||||
"@tsparticles/updater-opacity": "npm:4.3.2"
|
||||
"@tsparticles/updater-out-modes": "npm:4.3.2"
|
||||
"@tsparticles/updater-paint": "npm:4.3.2"
|
||||
"@tsparticles/updater-size": "npm:4.3.2"
|
||||
checksum: 10/ae74f4d2603dd3edbc5d798ff8fe62c099b17507adc9cc5132cc83425f5cbfd85d3acbfe0957251519ff9e92a4b238fdc25fe4b2c96f410bf28595b59ce900ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/canvas-utils@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/canvas-utils@npm:4.3.1"
|
||||
"@tsparticles/canvas-utils@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/canvas-utils@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/9ebb0cb2ce61c64b8acf262e65f4bdd5e1a5eecba739a87e31c0772eaf0d2934934e0284cc94302a4b6bf3380cc2aa6a126f057035ffc421603db4be22b3ad07
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/4c248ac87cb815cc18a0e0e5fec1beac03dc40f8de377783d4149ef1c8320962560af80110cfd9179676ed9b80175e84e711841d3860df01ea61a2cbce0413b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/engine@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/engine@npm:4.3.1"
|
||||
checksum: 10/175cb0b56146d521b740e4ab0189c45b93be180fd40ab97df250dcd1d52ca8701c31e94aac594dcfbd0115cf4d2de7ff7d46adc14cdf4a31be2fc3f3119b84f7
|
||||
"@tsparticles/engine@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/engine@npm:4.3.2"
|
||||
checksum: 10/f49c3656b23d71ebb13d218c389ec4c784ae74624e14a1253eb6edbdd24cf0b08cc734ab96d647cee02f87721180456a2cda15c892a3879241713ba2f8696fde
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/interaction-particles-links@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/interaction-particles-links@npm:4.3.1"
|
||||
"@tsparticles/interaction-particles-links@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/interaction-particles-links@npm:4.3.2"
|
||||
dependencies:
|
||||
"@tsparticles/canvas-utils": "npm:4.3.1"
|
||||
"@tsparticles/canvas-utils": "npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
"@tsparticles/plugin-interactivity": 4.3.1
|
||||
checksum: 10/120f91cc56a5174a4b9ce0526ab99a8ef2eaeb82369939d34c6c45a45d3691e73bce65c0e7ac177b5e0549a8acb1908017ed0677a49dc0ed9b3810d02f6d0bdb
|
||||
"@tsparticles/engine": 4.3.2
|
||||
"@tsparticles/plugin-interactivity": 4.3.2
|
||||
checksum: 10/89d046b2aa75efa07d1d242fdae7f39e260ad2dc6947b337089ae6708dcec250feb30273a2217c6da248ced2f6b99f05818bddf8b88c479bfd994e206b5122d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-blend@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-blend@npm:4.3.1"
|
||||
"@tsparticles/plugin-blend@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-blend@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/b100b5e2be00e3b5f1e8f189e928dad6a7fc95abab2feb22ca599522cc6bae95b10c9acc5173eb90dbf641fefe195f27f9020dda03c64d6db64a9c7ed51a03cf
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/27ed3682439d63830b169ee22c11b812517d1aa612d219b591c19ac8f2eb5497b5b48d59440c84845ce135bb6b13103fa896e6bed718132cfb2810f7b2feab7a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-hex-color@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-hex-color@npm:4.3.1"
|
||||
"@tsparticles/plugin-hex-color@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-hex-color@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/e9db18b29213522d383b34ed989ae735c1403f683a551c9cfe7c12ad40f4e4966546e376d473d550840a013687350e0206377a097ee4df9d0cd31610af555d62
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/942e1478031c79b97ff8da17dfe0cd7575065c14e6f7d07cc2b0e56a4e32fc499ea47bddf9d58936d4fa329a56c65ceade1d84d9acdae89ab7a600a8086ebeb2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-hsl-color@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-hsl-color@npm:4.3.1"
|
||||
"@tsparticles/plugin-hsl-color@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-hsl-color@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/fa6c43ef1a17da30a682608f7e91221584707853d2f27ff4567682d423cf2f36873c7ea4d7c3e5e8c6e152646554758c2cb9875ee3d2d52d53fa08a1608cc3fa
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/a507dc23ad0699b9ae9166eb27a2d7ecbec7d157fa1417f66612e4d18c0f513f6ded66c116c337ae2e32bbd5e76cdcb51df38a11ca30ac5f350751e9d0d40471
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-interactivity@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-interactivity@npm:4.3.1"
|
||||
"@tsparticles/plugin-interactivity@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-interactivity@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/ed4669a9dcd84a05f4c2e984483a0e75192a3ab556a63fd0130de081b2edd6baaa1aa4b1c7eb24f06b267fdcb0a1583416a7f1c4b0af023bf300ae01a27e45e1
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/0f31454bcc6c6e7e90fec585aa8cf6cd7efd33d4109067b7176ce0e8e885dad6b7d0b4b2b5d7b7354ade4c0a9ba038f43861b36ede59ffb72dd319ead8c20988
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-move@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-move@npm:4.3.1"
|
||||
"@tsparticles/plugin-move@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-move@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/38ad582a8315ae1da47edec4589213dd4e8b0fbaf9074e0c12f20744a4ea4ab4fac8da5a9c07c82f3356d79d24a6b204af585adfbdc01f14d00f67f0d975f7b7
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/e86e4e0f143f25c3079ab66fc9339c45e572aa59d4e11b9d056a772eec430c04e78f8c0ec25e5b0c541860b3e79d369d62d826c431b98cb2fadf57668f87a541
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/plugin-rgb-color@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/plugin-rgb-color@npm:4.3.1"
|
||||
"@tsparticles/plugin-rgb-color@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/plugin-rgb-color@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/a2ef532fbe00cddc8d118ddc00ffe1121121d5a8e30dd8875911f778aea25a2b48bea300dd93000f56e5b9d46ba509583ce0087138e4d378228857d0d0aea488
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/860f01c2e0d7491774c9fb7d00673bb2edec4e770dbb0926bc4397a04172e0be2c4caa541309a6e7a0f53e54260d8a15da891801a3899700b24b46da6eb04aca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/preset-links@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/preset-links@npm:4.3.1"
|
||||
"@tsparticles/preset-links@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/preset-links@npm:4.3.2"
|
||||
dependencies:
|
||||
"@tsparticles/basic": "npm:4.3.1"
|
||||
"@tsparticles/engine": "npm:4.3.1"
|
||||
"@tsparticles/interaction-particles-links": "npm:4.3.1"
|
||||
"@tsparticles/plugin-interactivity": "npm:4.3.1"
|
||||
checksum: 10/4084d84dbe0afec240e2ddadc46f3c5c3aa0c5fcd3d7b8cdf20377b27c9b68ec54b8a843c5212b2527e053c9c3a9f35826e1a95c4decf46cb681bbfccb87cd39
|
||||
"@tsparticles/basic": "npm:4.3.2"
|
||||
"@tsparticles/engine": "npm:4.3.2"
|
||||
"@tsparticles/interaction-particles-links": "npm:4.3.2"
|
||||
"@tsparticles/plugin-interactivity": "npm:4.3.2"
|
||||
checksum: 10/be949f8e5355e8edaf3c47f3ad65b149c8bee59e2839bbb1874f4add0a7054bdcd94e60150d099447ff6a755dfbe0b28d91188ff05e33bd62e0624056ef191f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/shape-circle@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/shape-circle@npm:4.3.1"
|
||||
"@tsparticles/shape-circle@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/shape-circle@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/257e841b9130ed7935b8ad9590265c41a134d6739f6b779ae080709f23054922fadb5071239f06bc03fa8df2d715b8e031933a37f4c55362832cc2d3b3ab0228
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/428e0576a1e990afce58a1e363269937546dae7cfb5eb158bef11c73708dce717e5ea8b5060d0782d800732a693b280df1a3c4655bf3aedb98d234e42745fbbe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/updater-opacity@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/updater-opacity@npm:4.3.1"
|
||||
"@tsparticles/updater-opacity@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/updater-opacity@npm:4.3.2"
|
||||
dependencies:
|
||||
"@tsparticles/animation-utils": "npm:4.3.1"
|
||||
"@tsparticles/animation-utils": "npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/cae7c72d06eebd412a7f47979abab3e675bc4efa864c2887c8cf540f524c212b0ec45672e7107ffb78a61ba58ef6359f616362fcb014864a2bf36e72b3c0db34
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/20f8dba30869efcfc0cbc11652d4aba29cb24156cb9a8818d561d8a946c7e7054a47df93c07313447f2a7c5f5238b5dd0c255eb46bf5de603f1affdf3188018a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/updater-out-modes@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/updater-out-modes@npm:4.3.1"
|
||||
"@tsparticles/updater-out-modes@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/updater-out-modes@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/adab89c27c622bbfb63a36a885eee6c372643a6d5da8fd67e6a23d0eae3793cb20a678e7eb7084b0629638f6212bc396daaf56652ad8f5770087bb4e49c9924a
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/4770c67b5cbc4b6219b0034fc51d9b414e0d284497b29f2d61ce94565bdfe7a69ca3f7e60b2e2b3ecea3927ea90fac29aa5c356cf47db5a6f51067653493e0e3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/updater-paint@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/updater-paint@npm:4.3.1"
|
||||
"@tsparticles/updater-paint@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/updater-paint@npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/df24e3fc4ffef48f6dd62792c940a3db9765f4f3b4aed376fa3d25356aebe33aacae7750c0a180ce711e2fbb2d5a869ae190325b8b6ffd013f92012ce96d0336
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/186f6ccc3a55ded85606ee99e44dd83239a3403b21ac485f70210ecd6e6b46c8f97a26645eef1b14a865dbb38957f0c8da3f46896b39e346de48922ca620cb71
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsparticles/updater-size@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tsparticles/updater-size@npm:4.3.1"
|
||||
"@tsparticles/updater-size@npm:4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "@tsparticles/updater-size@npm:4.3.2"
|
||||
dependencies:
|
||||
"@tsparticles/animation-utils": "npm:4.3.1"
|
||||
"@tsparticles/animation-utils": "npm:4.3.2"
|
||||
peerDependencies:
|
||||
"@tsparticles/engine": 4.3.1
|
||||
checksum: 10/b2b0a213b746be567bc42ea94d0dc3411ca9da2204a14107da2eff726c3b553550456cdc13e91e4495140754cddfb2de5ed57ecf7f86ddfa396b93b3e5a6bda9
|
||||
"@tsparticles/engine": 4.3.2
|
||||
checksum: 10/94d2998f85294b65bffd4792564d987d7c1a0f159c4724e33391bc65cdf8e5b57bef240604bb364e8a73de67e7f073f63a595abd1f8a0130c335209a8ea8d42f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8681,9 +8681,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.6.0":
|
||||
version: 10.6.0
|
||||
resolution: "eslint@npm:10.6.0"
|
||||
"eslint@npm:10.7.0":
|
||||
version: 10.7.0
|
||||
resolution: "eslint@npm:10.7.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -8722,7 +8722,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335
|
||||
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9736,15 +9736,15 @@ __metadata:
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.4.10"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.11"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.16"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-listformat": "npm:8.3.11"
|
||||
"@formatjs/intl-locale": "npm:5.3.9"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.12"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.11"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.11"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.5.0"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.12"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.17"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-listformat": "npm:8.3.12"
|
||||
"@formatjs/intl-locale": "npm:5.3.10"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.13"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.12"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.12"
|
||||
"@fullcalendar/core": "npm:6.1.21"
|
||||
"@fullcalendar/daygrid": "npm:6.1.21"
|
||||
"@fullcalendar/interaction": "npm:6.1.21"
|
||||
@@ -9752,7 +9752,7 @@ __metadata:
|
||||
"@fullcalendar/luxon3": "npm:6.1.21"
|
||||
"@fullcalendar/timegrid": "npm:6.1.21"
|
||||
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
|
||||
"@html-eslint/eslint-plugin": "npm:0.63.0"
|
||||
"@html-eslint/eslint-plugin": "npm:0.64.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.1.0"
|
||||
"@lit-labs/observers": "npm:2.1.0"
|
||||
@@ -9776,8 +9776,8 @@ __metadata:
|
||||
"@rspack/dev-server": "npm:2.1.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
"@tsparticles/engine": "npm:4.3.1"
|
||||
"@tsparticles/preset-links": "npm:4.3.1"
|
||||
"@tsparticles/engine": "npm:4.3.2"
|
||||
"@tsparticles/preset-links": "npm:4.3.2"
|
||||
"@types/babel__plugin-transform-runtime": "npm:7.9.5"
|
||||
"@types/chromecast-caf-receiver": "npm:6.0.26"
|
||||
"@types/chromecast-caf-sender": "npm:1.0.11"
|
||||
@@ -9814,7 +9814,7 @@ __metadata:
|
||||
dialog-polyfill: "npm:0.5.6"
|
||||
echarts: "npm:6.1.0"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.6.0"
|
||||
eslint: "npm:10.7.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
@@ -9838,7 +9838,7 @@ __metadata:
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.10"
|
||||
intl-messageformat: "npm:11.2.11"
|
||||
js-yaml: "npm:5.2.1"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
@@ -10186,13 +10186,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"intl-messageformat@npm:11.2.10":
|
||||
version: 11.2.10
|
||||
resolution: "intl-messageformat@npm:11.2.10"
|
||||
"intl-messageformat@npm:11.2.11":
|
||||
version: 11.2.11
|
||||
resolution: "intl-messageformat@npm:11.2.11"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.13"
|
||||
checksum: 10/d3f751ebfe2015cf4a011afe5679398dfa7156c0bc2ccd76ba8c5aa4e82946bd663e750538c97a8b4c946a6d5f5d19fbb56b2e159d422c416e51bba9b10b2746
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.14"
|
||||
checksum: 10/5074494588d6c16fb2730234b3c8eb80799189c9c21485323b5c119c22d3a7a8b385868b46479b81c09ca350d82ea0eeaf82b5e9799911b9a1b8bddbb2c3ad1c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user