mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-09 19:26:55 +00:00
Compare commits
4 Commits
e2e-app-perf
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e244a706b | |||
| 7911aef6c0 | |||
| ed00d4ded1 | |||
| be55a2197e |
@@ -18,10 +18,6 @@ runs:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
+40
-162
@@ -38,9 +38,8 @@ jobs:
|
||||
- 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
|
||||
@@ -66,9 +65,8 @@ jobs:
|
||||
- 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
|
||||
@@ -96,7 +94,6 @@ jobs:
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -106,27 +103,15 @@ 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
|
||||
# ── 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
|
||||
@@ -136,133 +121,60 @@ jobs:
|
||||
- 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:
|
||||
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
|
||||
with:
|
||||
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
|
||||
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 and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- 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
|
||||
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 and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- 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
|
||||
@@ -275,26 +187,12 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- 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
|
||||
@@ -311,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: |
|
||||
@@ -323,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 = {
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.5",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
@@ -168,7 +168,7 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
@@ -212,7 +212,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.62.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -15,7 +16,12 @@ import {
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
|
||||
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
.getItems=${this._getItems(value, this._automations)}
|
||||
@value-changed=${this._automationChanged}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.actions.unknown_action"
|
||||
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
);
|
||||
|
||||
// Device automation labels (entity name + subtype) are often longer than the
|
||||
// field, so let the option wrap onto multiple lines instead of truncating.
|
||||
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact multiline>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const automation = this._automations?.find(
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
|
||||
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
@property({ type: Boolean, reflect: true, attribute: "border-top" })
|
||||
public borderTop = false;
|
||||
|
||||
// Allow the headline/supporting text to wrap onto multiple lines instead of
|
||||
// truncating with an ellipsis. Off by default to preserve single-line rows.
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public multiline = false;
|
||||
|
||||
static override styles = [
|
||||
...haMdListStyles,
|
||||
css`
|
||||
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host([multiline]) [slot="headline"],
|
||||
:host([multiline]) [slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img) {
|
||||
width: 32px;
|
||||
|
||||
@@ -117,10 +117,14 @@ export interface SetZwaveUserParams {
|
||||
user_type?: string;
|
||||
credential_rule?: string;
|
||||
active?: boolean;
|
||||
credential_type?: ZwaveCredentialType;
|
||||
credential_slot?: number;
|
||||
credential_data?: string;
|
||||
}
|
||||
|
||||
export interface SetZwaveUserResult {
|
||||
user_id: number;
|
||||
credential_slot?: number | null;
|
||||
}
|
||||
|
||||
export interface SetZwaveCredentialParams {
|
||||
|
||||
+5
-17
@@ -19,7 +19,6 @@ import {
|
||||
DEFAULT_CREDENTIAL_MIN_LENGTH,
|
||||
ENTERABLE_ZWAVE_CREDENTIAL_TYPES,
|
||||
deleteZwaveCredential,
|
||||
deleteZwaveUser,
|
||||
enterableCredentialTypes,
|
||||
getCredentialError,
|
||||
compatibleUserTypes,
|
||||
@@ -539,27 +538,16 @@ class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFo
|
||||
credentialType: ZwaveCredentialType
|
||||
): Promise<void> {
|
||||
const params = this._params!;
|
||||
const { user_id } = await setZwaveUser(this.hass, params.entity_id, {
|
||||
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
|
||||
user_type: this._userType,
|
||||
active: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await setZwaveCredential(this.hass, params.entity_id, {
|
||||
user_id,
|
||||
await setZwaveUser(this.hass, params.entity_id, {
|
||||
// Omit user_id to create a new user with the given credential.
|
||||
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
|
||||
user_type: this._userType,
|
||||
active: true,
|
||||
credential_type: credentialType,
|
||||
credential_data: this._credentialData,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Roll back the user so the lock returns to its prior state. We
|
||||
// ignore rollback errors — the credential error is the actionable
|
||||
// one to surface; a stranded user will reappear on next refresh.
|
||||
try {
|
||||
await deleteZwaveUser(this.hass, params.entity_id, user_id);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.zwave_js.credentials.errors.add_user_failed",
|
||||
{
|
||||
|
||||
@@ -169,6 +169,49 @@ describe("zwave_js-credentials", () => {
|
||||
);
|
||||
expect(result.user_id).toBe(1);
|
||||
});
|
||||
|
||||
it("creates a new user with a credential and returns the slot", async () => {
|
||||
// Omitting user_id makes set_user create a new user and write the
|
||||
// credential in one call, returning the allocated credential_slot.
|
||||
const hass = setUserResponse({ user_id: 1, credential_slot: 1 });
|
||||
|
||||
const result = await setZwaveUser(hass, ENTITY_ID, {
|
||||
user_name: "Alice",
|
||||
user_type: "general",
|
||||
active: true,
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
});
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith(
|
||||
"zwave_js",
|
||||
"set_user",
|
||||
{
|
||||
user_name: "Alice",
|
||||
user_type: "general",
|
||||
active: true,
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
},
|
||||
{ entity_id: ENTITY_ID },
|
||||
false,
|
||||
true
|
||||
);
|
||||
expect(result).toEqual({ user_id: 1, credential_slot: 1 });
|
||||
});
|
||||
|
||||
it("propagates errors from callService", async () => {
|
||||
const hass = {
|
||||
callService: vi.fn().mockRejectedValue(new Error("No slots")),
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
await expect(
|
||||
setZwaveUser(hass, ENTITY_ID, {
|
||||
credential_type: "pin_code",
|
||||
credential_data: "1234",
|
||||
})
|
||||
).rejects.toThrow("No slots");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteZwaveUser", () => {
|
||||
|
||||
+463
-69
@@ -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);
|
||||
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");
|
||||
await historyLink.click({ force: true });
|
||||
const sidebar = page.locator(
|
||||
"ha-test >> home-assistant-main >> ha-sidebar"
|
||||
);
|
||||
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_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: 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,186 +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;
|
||||
projects?: string[];
|
||||
url?: RegExp;
|
||||
action?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RouteSmokeGroup {
|
||||
name: string;
|
||||
routes: RouteSmokeCase[];
|
||||
testName?: (route: RouteSmokeCase) => string;
|
||||
projects?: 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,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRunRouteSmoke(
|
||||
projectName: string,
|
||||
group: RouteSmokeGroup,
|
||||
route: RouteSmokeCase
|
||||
) {
|
||||
return (route.projects ?? group.projects)?.includes(projectName) ?? true;
|
||||
}
|
||||
|
||||
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, testInfo, group, smokeCase }) => {
|
||||
test.skip(
|
||||
!shouldRunRouteSmoke(testInfo.project.name, group, smokeCase),
|
||||
"Route smoke coverage does not run for this project"
|
||||
);
|
||||
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 { relative } from "path";
|
||||
|
||||
const findBlobReports = (dir) => {
|
||||
const files = [];
|
||||
const walk = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const path = `${currentDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
walk(path);
|
||||
} else if (entry.name.endsWith(".zip")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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, `${dest}/${suite}-${name}`);
|
||||
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
+107
-39
@@ -1,78 +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 {
|
||||
clickFirstVisibleDemoSidebarPanel,
|
||||
demoCardSelector,
|
||||
moreInfoCardSelector,
|
||||
openDemoSidebar,
|
||||
waitForDemoReady,
|
||||
} from "./demo/helpers";
|
||||
|
||||
test.describe("Home Assistant Demo", () => {
|
||||
// Collect JS errors during each test so we can assert no unexpected crashes.
|
||||
let pageErrors: Error[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err));
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
function appErrors() {
|
||||
return filterAppErrors(pageErrors);
|
||||
}
|
||||
|
||||
// ── 1. Page loads ──────────────────────────────────────────────────────────
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
// The custom element is present in the document
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
await waitForDemoReady(page);
|
||||
// The launch screen should disappear once the app is ready
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
expectNoPageErrors(errors);
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
const errors = trackPageErrors(page);
|
||||
await expect(page.locator("ha-demo")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
|
||||
await waitForDemoReady(page);
|
||||
await openDemoSidebar(page);
|
||||
// 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 clicked = await clickFirstVisibleDemoSidebarPanel(page, [
|
||||
"map",
|
||||
"logbook",
|
||||
"history",
|
||||
"config",
|
||||
]);
|
||||
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);
|
||||
expectNoPageErrors(errors);
|
||||
expect(appErrors()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors = trackPageErrors(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(errors);
|
||||
// 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,69 +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()) {
|
||||
await menuButton.click();
|
||||
await expect(page.locator("ha-sidebar")).toBeVisible({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.locator("ha-sidebar")).toBeAttached({
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickFirstVisibleDemoSidebarPanel(
|
||||
page: Page,
|
||||
panels: string[]
|
||||
) {
|
||||
const panelSelectors = panels.map((panel) => `#sidebar-panel-${panel}`);
|
||||
await expect(page.locator(panelSelectors.join(", ")).first()).toBeVisible({
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
|
||||
const navItems = await Promise.all(
|
||||
panels.map(async (panel) => {
|
||||
const navItem = page.locator(`#sidebar-panel-${panel}`);
|
||||
return {
|
||||
panel,
|
||||
navItem,
|
||||
isVisible: await navItem.isVisible().catch(() => false),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const visibleNavItem = navItems.find(({ isVisible }) => isVisible);
|
||||
if (!visibleNavItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await visibleNavItem.navItem.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${visibleNavItem.panel}`), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
+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_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_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,94 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import {
|
||||
defineParallelSmokeTests,
|
||||
expectNoPageErrors,
|
||||
QUICK_TIMEOUT,
|
||||
SHELL_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "../helpers";
|
||||
|
||||
export const GALLERY_IGNORED_PAGE_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'\)/,
|
||||
];
|
||||
|
||||
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
-70
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Shared helpers and constants for Playwright e2e suites.
|
||||
*/
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
// ── Timeouts ────────────────────────────────────────────────────────────────
|
||||
// Centralised so tweaks don't require search-and-replace across spec files.
|
||||
@@ -18,73 +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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
testInfo: TestInfo;
|
||||
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), () => {
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
for (const smokeCase of cases(group)) {
|
||||
test(testName(smokeCase, group), async ({ page }, testInfo) => {
|
||||
await run({ page, testInfo, 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,14 +0,0 @@
|
||||
const DEFAULT_LOCAL_WORKERS = "60%";
|
||||
|
||||
export const getE2EWorkers = (): number | string => {
|
||||
if (process.env.CI) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const workers = process.env.E2E_WORKERS;
|
||||
if (!workers) {
|
||||
return DEFAULT_LOCAL_WORKERS;
|
||||
}
|
||||
|
||||
return /^[1-9]\d*$/.test(workers) ? Number(workers) : 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
-122
@@ -1,82 +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.
|
||||
// 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 isTruthy = (value) => TRUE_VALUES.has(value?.toLowerCase() ?? "");
|
||||
|
||||
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) {
|
||||
@@ -84,62 +18,32 @@ if (!suites.length) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sequential = isTruthy(process.env.E2E_SEQUENTIAL);
|
||||
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) {
|
||||
for (const suite of suites) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
results.push(await runSuite(suite, suiteEnv));
|
||||
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);
|
||||
}
|
||||
} 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(
|
||||
|
||||
@@ -2454,15 +2454,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/view@npm:6.43.5, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
|
||||
version: 6.43.5
|
||||
resolution: "@codemirror/view@npm:6.43.5"
|
||||
"@codemirror/view@npm:6.43.6, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
|
||||
version: 6.43.6
|
||||
resolution: "@codemirror/view@npm:6.43.6"
|
||||
dependencies:
|
||||
"@codemirror/state": "npm:^6.7.0"
|
||||
crelt: "npm:^1.0.6"
|
||||
style-mod: "npm:^4.1.0"
|
||||
w3c-keyname: "npm:^2.2.4"
|
||||
checksum: 10/24e28f57d84d1db5c140f704c1af8591c279099d26f864a12a16cdd8a372a10048327ea81d475da57d0e84ab9ac49ad71209d54aa1211dd81d79a144e5b6039c
|
||||
checksum: 10/be058e86523d5770921c6bad7963eb5ef0a7db340922db93043394e1e93d1f3125611a3c2dbf143f1ab69b426038cd2034ffa7484d4b55d1ac909f2ceed2ee06
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6142,12 +6142,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/coverage-v8@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.9"
|
||||
"@vitest/coverage-v8@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/coverage-v8@npm:4.1.10"
|
||||
dependencies:
|
||||
"@bcoe/v8-coverage": "npm:^1.0.2"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
ast-v8-to-istanbul: "npm:^1.0.0"
|
||||
istanbul-lib-coverage: "npm:^3.2.2"
|
||||
istanbul-lib-report: "npm:^3.0.1"
|
||||
@@ -6157,34 +6157,34 @@ __metadata:
|
||||
std-env: "npm:^4.0.0-rc.1"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
peerDependencies:
|
||||
"@vitest/browser": 4.1.9
|
||||
vitest: 4.1.9
|
||||
"@vitest/browser": 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
"@vitest/browser":
|
||||
optional: true
|
||||
checksum: 10/1f236e17336973868aa6e7662b863b1c519d07840107daab3465429652741fc1f8a8988d1b7b28b8cfa883c7280f7479927a85e56c7874a0ddc5cc8ceda25cd6
|
||||
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/expect@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/expect@npm:4.1.9"
|
||||
"@vitest/expect@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/expect@npm:4.1.10"
|
||||
dependencies:
|
||||
"@standard-schema/spec": "npm:^1.1.0"
|
||||
"@types/chai": "npm:^5.2.2"
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
chai: "npm:^6.2.2"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/aba1a06cd28199f9c861d97797b014c0584fa6f6197e78345da0db5f74914d47f18958bb848658e889ca44452aa61e07ae851c16ea7b2175afd50d649dd4ed8c
|
||||
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/mocker@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/mocker@npm:4.1.9"
|
||||
"@vitest/mocker@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/mocker@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
estree-walker: "npm:^3.0.3"
|
||||
magic-string: "npm:^0.30.21"
|
||||
peerDependencies:
|
||||
@@ -6195,56 +6195,56 @@ __metadata:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
checksum: 10/3e35ff3e2ecbdfbcae598e9c5c83978dd5f0cf3b16df37cf947c80faabce797ab275ca2075c3bb8ca85f595f3070267f93cb6798bbe415f1af2698f51833974c
|
||||
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/pretty-format@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/pretty-format@npm:4.1.9"
|
||||
"@vitest/pretty-format@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/pretty-format@npm:4.1.10"
|
||||
dependencies:
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/52512b300c000594c54bebbbfe31fab39e416a35d3686e2c46bc8e48ef8476d32306605f7736139608c3962943e0d22790dc15a3e6b1ffa436143d31f743a7c8
|
||||
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/runner@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/runner@npm:4.1.9"
|
||||
"@vitest/runner@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/runner@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/52e4e16e627faa62676f17683e570f505d58d2ce0ef421a3ae60e70c0ec5606d4af090fa6c7d5717d6e949f4401d6357b1f69cf06e52a5455a0ad9c9040268c0
|
||||
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/snapshot@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/snapshot@npm:4.1.9"
|
||||
"@vitest/snapshot@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/snapshot@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
magic-string: "npm:^0.30.21"
|
||||
pathe: "npm:^2.0.3"
|
||||
checksum: 10/c83349b1ad08d48284c1d3393168a7b7faffd24ace1ef337751a568dad322d83b0f9bc29378a4a60379cf2a13a268092b1d802936d6adb1ca28859f02dad8b87
|
||||
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/spy@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/spy@npm:4.1.9"
|
||||
checksum: 10/8b8e42cc8e4b20d29bd8b312f34b9dbf2e20d4b4cdc24e3bcf6fd4d3b1f49e8924636d2730cca3946fbb45de893dfb531c77b832eb853c2624fdc2b800444e75
|
||||
"@vitest/spy@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/spy@npm:4.1.10"
|
||||
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/utils@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "@vitest/utils@npm:4.1.9"
|
||||
"@vitest/utils@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "@vitest/utils@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/pretty-format": "npm:4.1.9"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
convert-source-map: "npm:^2.0.0"
|
||||
tinyrainbow: "npm:^3.1.0"
|
||||
checksum: 10/78f5969fc09b1a95fda9dadd37e84a3a6ead35f66af15ad3b792eef35f80407047803e7afd53df86a8d794f59bf25ffbdc4146099140a3d5f9b51ea061bf2308
|
||||
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9732,7 +9732,7 @@ __metadata:
|
||||
"@codemirror/lint": "npm:6.9.7"
|
||||
"@codemirror/search": "npm:6.7.1"
|
||||
"@codemirror/state": "npm:6.7.1"
|
||||
"@codemirror/view": "npm:6.43.5"
|
||||
"@codemirror/view": "npm:6.43.6"
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
@@ -9793,7 +9793,7 @@ __metadata:
|
||||
"@types/sortablejs": "npm:1.15.9"
|
||||
"@types/tar": "npm:7.0.87"
|
||||
"@vibrant/color": "npm:4.0.4"
|
||||
"@vitest/coverage-v8": "npm:4.1.9"
|
||||
"@vitest/coverage-v8": "npm:4.1.10"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
|
||||
"@webcomponents/webcomponentsjs": "npm:2.8.0"
|
||||
@@ -9879,7 +9879,7 @@ __metadata:
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.62.1"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.9"
|
||||
vitest: "npm:4.1.10"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
webpackbar: "npm:7.0.0"
|
||||
weekstart: "npm:2.0.0"
|
||||
@@ -15367,17 +15367,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vitest@npm:4.1.9":
|
||||
version: 4.1.9
|
||||
resolution: "vitest@npm:4.1.9"
|
||||
"vitest@npm:4.1.10":
|
||||
version: 4.1.10
|
||||
resolution: "vitest@npm:4.1.10"
|
||||
dependencies:
|
||||
"@vitest/expect": "npm:4.1.9"
|
||||
"@vitest/mocker": "npm:4.1.9"
|
||||
"@vitest/pretty-format": "npm:4.1.9"
|
||||
"@vitest/runner": "npm:4.1.9"
|
||||
"@vitest/snapshot": "npm:4.1.9"
|
||||
"@vitest/spy": "npm:4.1.9"
|
||||
"@vitest/utils": "npm:4.1.9"
|
||||
"@vitest/expect": "npm:4.1.10"
|
||||
"@vitest/mocker": "npm:4.1.10"
|
||||
"@vitest/pretty-format": "npm:4.1.10"
|
||||
"@vitest/runner": "npm:4.1.10"
|
||||
"@vitest/snapshot": "npm:4.1.10"
|
||||
"@vitest/spy": "npm:4.1.10"
|
||||
"@vitest/utils": "npm:4.1.10"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
expect-type: "npm:^1.3.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
@@ -15395,12 +15395,12 @@ __metadata:
|
||||
"@edge-runtime/vm": "*"
|
||||
"@opentelemetry/api": ^1.9.0
|
||||
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
"@vitest/browser-playwright": 4.1.9
|
||||
"@vitest/browser-preview": 4.1.9
|
||||
"@vitest/browser-webdriverio": 4.1.9
|
||||
"@vitest/coverage-istanbul": 4.1.9
|
||||
"@vitest/coverage-v8": 4.1.9
|
||||
"@vitest/ui": 4.1.9
|
||||
"@vitest/browser-playwright": 4.1.10
|
||||
"@vitest/browser-preview": 4.1.10
|
||||
"@vitest/browser-webdriverio": 4.1.10
|
||||
"@vitest/coverage-istanbul": 4.1.10
|
||||
"@vitest/coverage-v8": 4.1.10
|
||||
"@vitest/ui": 4.1.10
|
||||
happy-dom: "*"
|
||||
jsdom: "*"
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -15431,7 +15431,7 @@ __metadata:
|
||||
optional: false
|
||||
bin:
|
||||
vitest: ./vitest.mjs
|
||||
checksum: 10/64f9d1a0aae92c493c39822ecae8ec5b5a336fc27166f776d08c01ae79ef1ec5485a1826ef1451bb05df2edaae109894125c2ecceadaa56c17be2690f66f9758
|
||||
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user