Compare commits

..

1 Commits

Author SHA1 Message Date
Bram Kragten 2e91b166ed Show warning when priming will not work for condition 2026-06-17 16:32:33 +02:00
62 changed files with 356 additions and 5376 deletions
-308
View File
@@ -1,308 +0,0 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
@@ -1,190 +0,0 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
- reopened
- ready_for_review
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check pull request follows contribution standards
runs-on: ubuntu-latest
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
);
-8
View File
@@ -54,16 +54,8 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
-53
View File
@@ -1,53 +0,0 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+1 -18
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -320,22 +321,4 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
-4
View File
@@ -1,13 +1,9 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,3 +1,4 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
-7
View File
@@ -45,10 +45,3 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
-41
View File
@@ -1,41 +0,0 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+1 -21
View File
@@ -1,3 +1,4 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -267,24 +268,3 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
-20
View File
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
-1
View File
@@ -4,7 +4,6 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+29 -69
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access, readdir } from "fs/promises";
import { readFile, access } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,98 +11,58 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licenseFile: "license-mit",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
if (!packageDir) {
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
-20
View File
@@ -14,7 +14,6 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -232,22 +231,3 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
-11
View File
@@ -50,15 +50,4 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+1 -6
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -337,11 +338,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -349,5 +345,4 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
-21
View File
@@ -1,21 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
-5
View File
@@ -1,5 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
-6
View File
@@ -234,12 +234,6 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+1 -13
View File
@@ -22,16 +22,7 @@
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -146,11 +137,9 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -169,7 +158,6 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
@@ -16,12 +16,14 @@ interface CacheResult<T> {
* @param args extra arguments to pass to the function to fetch the data
* @returns
*/
export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
export const timeCachePromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (hass: H, ...args: any[]) => Promise<T>,
generateCacheKey: ((hass: H, lastResult: T) => unknown) | undefined,
hass: H,
func: (hass: HomeAssistant, ...args: any[]) => Promise<T>,
generateCacheKey:
| ((hass: HomeAssistant, lastResult: T) => unknown)
| undefined,
hass: HomeAssistant,
...args: any[]
): Promise<T> => {
const anyHass = hass as any;
+9 -3
View File
@@ -101,9 +101,15 @@ export class HaLabelsPicker extends LitElement {
language: string
) =>
value
?.map((id) => labels?.find((label) => label.label_id === id))
.filter((label): label is LabelRegistryEntry => label !== undefined)
.sort((a, b) => stringCompare(a.name, b.name, language))
?.map(
(id) =>
labels?.find((label) => label.label_id === id) || {
label_id: id,
name: id,
color: "rgba(var(--rgb-primary-text-color), 0.15)",
}
)
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
.map((label) => ({
...label,
style: getLabelColorStyle(label.color),
+1 -1
View File
@@ -181,7 +181,7 @@ export interface RestoreBackupParams {
restore_homeassistant?: boolean;
}
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
export const fetchBackupConfig = (hass: HomeAssistant) =>
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
export const updateBackupConfig = (
+3 -4
View File
@@ -7,12 +7,11 @@ interface EntitySource {
export type EntitySources = Record<string, EntitySource>;
const fetchEntitySources = (
hass: Pick<HomeAssistant, "callWS">
): Promise<EntitySources> => hass.callWS({ type: "entity/source" });
const fetchEntitySources = (hass: HomeAssistant): Promise<EntitySources> =>
hass.callWS({ type: "entity/source" });
export const fetchEntitySourcesWithCache = (
hass: Pick<HomeAssistant, "callWS" | "states">
hass: HomeAssistant
): Promise<EntitySources> =>
timeCachePromiseFunc(
"_entitySources",
+15
View File
@@ -153,6 +153,21 @@ export const getRecorderInfo = (conn: Connection) =>
type: "recorder/info",
});
export type EntityRecordingDisabler = "user";
export interface RecordedEntityOptions {
recording_disabled_by: EntityRecordingDisabler | null;
}
export const getRecordedEntity = (
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<RecordedEntityOptions>({
type: "recorder/recorded_entities/get",
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
+1 -3
View File
@@ -6,9 +6,7 @@ export interface SupervisorUpdateConfig {
core_backup_before_update: boolean;
}
export const getSupervisorUpdateConfig = async (
hass: Pick<HomeAssistant, "callWS">
) =>
export const getSupervisorUpdateConfig = async (hass: HomeAssistant) =>
hass.callWS<SupervisorUpdateConfig>({
type: "hassio/update/config/info",
});
-10
View File
@@ -3,10 +3,8 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { createDurationData } from "../common/datetime/create_duration_data";
import durationToSeconds from "../common/datetime/duration_to_seconds";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
export type TimerEntity = HassEntityBase & {
@@ -102,11 +100,3 @@ export const computeDisplayTimer = (
return display;
};
// Prefill for the duration input: always the configured duration, independent
// of the live countdown. The field is meant to be edited, not to mirror the
// remaining time.
export const timerDurationData = (
stateObj: HassEntity
): HaDurationData | undefined =>
createDurationData(stateObj.attributes.duration);
+1 -4
View File
@@ -77,10 +77,7 @@ export const updateButtonIsDisabled = (entity: UpdateEntity): boolean =>
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
!!entity.attributes.in_progress;
export const updateReleaseNotes = (
hass: Pick<HomeAssistant, "callWS">,
entityId: string
) =>
export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
hass.callWS<string | null>({
type: "update/release_notes",
entity_id: entityId,
@@ -1,12 +1,8 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-duration-input";
import type { HaDurationData } from "../../../components/ha-duration-input";
import type { TimerEntity } from "../../../data/timer";
import { timerDurationData } from "../../../data/timer";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-timer")
class MoreInfoTimer extends LitElement {
@@ -14,58 +10,14 @@ class MoreInfoTimer extends LitElement {
@property({ attribute: false }) public stateObj?: TimerEntity;
@state() private _duration?: HaDurationData;
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Seed the field once from the configured duration and keep it static,
// so it never jumps to the live remaining time as the timer ticks.
if (this._duration === undefined && this.stateObj) {
this._duration = timerDurationData(this.stateObj);
}
}
protected render() {
if (!this.hass || !this.stateObj) {
return nothing;
}
const timerState = this.stateObj.state;
return html`
<ha-duration-input
.data=${this._duration}
required
@value-changed=${this._durationChanged}
></ha-duration-input>
<div class="actions">
${timerState === "idle"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this.hass.localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing}
${timerState === "active" || timerState === "paused"
? html`
<ha-button appearance="plain" size="s" @click=${this._start}>
${this.hass.localize("ui.card.timer.actions.set")}
</ha-button>
`
: nothing}
${timerState === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this.hass.localize("ui.card.timer.actions.pause")}
</ha-button>
`
: nothing}
${timerState === "paused"
${this.stateObj.state === "idle" || this.stateObj.state === "paused"
? html`
<ha-button
appearance="plain"
@@ -73,11 +25,23 @@ class MoreInfoTimer extends LitElement {
.action=${"start"}
@click=${this._handleActionClick}
>
${this.hass.localize("ui.card.timer.actions.start")}
${this.hass!.localize("ui.card.timer.actions.start")}
</ha-button>
`
: nothing}
${timerState === "active" || timerState === "paused"
: ""}
${this.stateObj.state === "active"
? html`
<ha-button
appearance="plain"
size="s"
.action=${"pause"}
@click=${this._handleActionClick}
>
${this.hass!.localize("ui.card.timer.actions.pause")}
</ha-button>
`
: ""}
${this.stateObj.state === "active" || this.stateObj.state === "paused"
? html`
<ha-button
appearance="plain"
@@ -85,7 +49,7 @@ class MoreInfoTimer extends LitElement {
.action=${"cancel"}
@click=${this._handleActionClick}
>
${this.hass.localize("ui.card.timer.actions.cancel")}
${this.hass!.localize("ui.card.timer.actions.cancel")}
</ha-button>
<ha-button
appearance="plain"
@@ -93,30 +57,14 @@ class MoreInfoTimer extends LitElement {
.action=${"finish"}
@click=${this._handleActionClick}
>
${this.hass.localize("ui.card.timer.actions.finish")}
${this.hass!.localize("ui.card.timer.actions.finish")}
</ha-button>
`
: nothing}
: ""}
</div>
`;
}
private _durationChanged(
ev: ValueChangedEvent<HaDurationData | undefined>
): void {
this._duration = ev.detail.value;
}
// Used by idle "Start" and active/paused "Set": (re)starts the timer with the
// entered duration. timer.start has no upper bound, so values beyond the
// configured duration are accepted.
private _start(): void {
this.hass.callService("timer", "start", {
entity_id: this.stateObj!.entity_id,
...(this._duration ? { duration: this._duration } : {}),
});
}
private _handleActionClick(e: MouseEvent): void {
const action = (e.currentTarget as any).action;
this.hass.callService("timer", action, {
@@ -125,16 +73,10 @@ class MoreInfoTimer extends LitElement {
}
static styles = css`
ha-duration-input {
display: flex;
justify-content: center;
margin: var(--ha-space-4) 0 var(--ha-space-2);
}
.actions {
margin: var(--ha-space-2) 0;
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
justify-content: center;
}
`;
@@ -1,14 +1,9 @@
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { BINARY_STATE_OFF } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -20,18 +15,10 @@ import "../../../components/item/ha-row-item";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import {
apiContext,
configContext,
formattersContext,
internationalizationContext,
statesContext,
} from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../data/update";
import {
getUpdateType,
@@ -41,49 +28,15 @@ import {
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import type { HomeAssistant } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: UpdateEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@@ -98,7 +51,7 @@ class MoreInfoUpdate extends LitElement {
private async _fetchBackupConfig() {
try {
const { config } = await fetchBackupConfig(this._api);
const { config } = await fetchBackupConfig(this.hass);
this._backupConfig = config;
} catch (err) {
// ignore error, because user will get a manual backup option
@@ -109,7 +62,7 @@ class MoreInfoUpdate extends LitElement {
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
const config = await getSupervisorUpdateConfig(this._api);
const config = await getSupervisorUpdateConfig(this.hass);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
@@ -128,10 +81,7 @@ class MoreInfoUpdate extends LitElement {
}
private async _fetchEntitySources() {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
@@ -161,10 +111,10 @@ class MoreInfoUpdate extends LitElement {
if (!isBackupConfigValid) {
return {
title: this._localize(
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
description: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
@@ -177,22 +127,22 @@ class MoreInfoUpdate extends LitElement {
const now = new Date();
return {
title: this._localize(
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
? this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
this.hass.locale,
now,
true
),
}
)
: this._localize(
: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
@@ -202,11 +152,11 @@ class MoreInfoUpdate extends LitElement {
if (updateType === "addon") {
const version = this.stateObj.attributes.installed_version;
return {
title: this._localize(
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
? this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
@@ -216,7 +166,7 @@ class MoreInfoUpdate extends LitElement {
// Fallback to generic UI
return {
title: this._localize(
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
@@ -224,7 +174,7 @@ class MoreInfoUpdate extends LitElement {
protected render() {
if (
!this._localize ||
!this.hass ||
!this.stateObj ||
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
@@ -252,26 +202,26 @@ class MoreInfoUpdate extends LitElement {
: nothing}
<div class="row">
<div class="key">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"installed_version"
)}
</div>
<div class="value">
${this.stateObj.attributes.installed_version ??
this._localize("state.default.unavailable")}
this.hass.localize("state.default.unavailable")}
</div>
</div>
<div class="row">
<div class="key">
${this._formatters.formatEntityAttributeName(
${this.hass.formatEntityAttributeName(
this.stateObj,
"latest_version"
)}
</div>
<div class="value">
${this.stateObj.attributes.latest_version ??
this._localize("state.default.unavailable")}
this.hass.localize("state.default.unavailable")}
</div>
</div>
@@ -283,7 +233,7 @@ class MoreInfoUpdate extends LitElement {
target="_blank"
rel="noreferrer"
>
${this._localize(
${this.hass.localize(
"ui.dialogs.more_info_control.update.release_announcement"
)}
</a>
@@ -350,7 +300,7 @@ class MoreInfoUpdate extends LitElement {
appearance="plain"
@click=${this._handleClearSkipped}
>
${this._localize(
${this.hass.localize(
"ui.dialogs.more_info_control.update.clear_skipped"
)}
</ha-button>
@@ -363,7 +313,9 @@ class MoreInfoUpdate extends LitElement {
this.stateObj.state === BINARY_STATE_OFF ||
updateIsInstalling(this.stateObj)}
>
${this._localize("ui.dialogs.more_info_control.update.skip")}
${this.hass.localize(
"ui.dialogs.more_info_control.update.skip"
)}
</ha-button>
`}
${supportsFeature(this.stateObj, UpdateEntityFeature.INSTALL)
@@ -373,7 +325,7 @@ class MoreInfoUpdate extends LitElement {
.loading=${updateIsInstalling(this.stateObj)}
.disabled=${updateButtonIsDisabled(this.stateObj)}
>
${this._localize(
${this.hass.localize(
"ui.dialogs.more_info_control.update.update"
)}
</ha-button>
@@ -400,7 +352,7 @@ class MoreInfoUpdate extends LitElement {
this._fetchEntitySources().then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
isComponentLoaded(this.hass.config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
@@ -422,7 +374,7 @@ class MoreInfoUpdate extends LitElement {
private async _fetchReleaseNotes() {
try {
this._releaseNotes = await updateReleaseNotes(
this._api,
this.hass,
this.stateObj!.entity_id
);
} catch (err: any) {
@@ -453,7 +405,7 @@ class MoreInfoUpdate extends LitElement {
installData.version = this.stateObj!.attributes.latest_version;
}
this._api.callService("update", "install", installData);
this.hass.callService("update", "install", installData);
}
private _createBackupChanged(ev) {
@@ -463,22 +415,22 @@ class MoreInfoUpdate extends LitElement {
private _handleSkip(): void {
if (this.stateObj!.attributes.auto_update) {
showAlertDialog(this, {
title: this._localize(
title: this.hass.localize(
"ui.dialogs.more_info_control.update.auto_update_enabled_title"
),
text: this._localize(
text: this.hass.localize(
"ui.dialogs.more_info_control.update.auto_update_enabled_text"
),
});
return;
}
this._api.callService("update", "skip", {
this.hass.callService("update", "skip", {
entity_id: this.stateObj!.entity_id,
});
}
private _handleClearSkipped(): void {
this._api.callService("update", "clear_skipped", {
this.hass.callService("update", "clear_skipped", {
entity_id: this.stateObj!.entity_id,
});
}
+1 -1
View File
@@ -170,7 +170,7 @@ class DialogRestart extends LitElement {
</ha-list-base>
<ha-expansion-panel
.header=${this.hass.localize(
"ui.dialogs.restart.more_options"
"ui.dialogs.restart.advanced_options"
)}
>
<ha-list-base>
-4
View File
@@ -457,10 +457,6 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+13 -2
View File
@@ -2,6 +2,7 @@ import { mdiOpenInNew, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
@@ -57,9 +58,14 @@ class HaConfigAppsInfo extends LitElement {
</h2>
<p>
${this.hass.localize(
"ui.panel.config.apps.info.installation_hint"
"ui.panel.config.apps.info.why_not_available_description"
)}
</p>
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.apps.info.installation_hint"
)}
</ha-alert>
</div>
<div class="card-actions">
<ha-button
@@ -138,11 +144,16 @@ class HaConfigAppsInfo extends LitElement {
}
p {
margin: 0;
margin: 0 0 var(--ha-space-3);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
}
ha-alert {
display: block;
margin-top: var(--ha-space-2);
}
.card-actions {
display: flex;
justify-content: space-between;
@@ -3,11 +3,16 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformCondition } from "../../../../../data/automation";
import type {
ForDict,
PlatformCondition,
} from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
@@ -15,11 +20,21 @@ import {
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { getRecordedEntity } from "../../../../../data/recorder";
import type { TargetSelector } from "../../../../../data/selector";
import { getTargetEntityCount } from "../../../../../data/target";
import {
extractFromTarget,
getTargetEntityCount,
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
// when a condition has a `for:` duration, the recorder is only queried this far
// back to prime it at setup, so longer durations can't be fully satisfied from
// history after a restart or reload.
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
@@ -41,6 +56,11 @@ export class HaPlatformCondition extends LitElement {
@state() private _resolvedTargetEntityCount?: number;
@state() private _targetHasUnrecordedEntity = false;
// Incremented on each recording check so stale async responses are ignored.
private _recordingCheckToken = 0;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
@@ -51,6 +71,26 @@ export class HaPlatformCondition extends LitElement {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
// The `for:` priming info depends on both the condition (target + duration)
// and the description (whether the condition targets entities at all), which
// can arrive in separate updates.
if (
changedProperties.has("condition") ||
changedProperties.has("description")
) {
const previousCondition = changedProperties.get("condition") as
| undefined
| this["condition"];
if (
changedProperties.has("description") ||
previousCondition?.target !== this.condition?.target ||
previousCondition?.options?.for !== this.condition?.options?.for
) {
this._updateDurationPrimingInfo();
}
}
if (!changedProperties.has("condition")) {
return;
}
@@ -206,6 +246,7 @@ export class HaPlatformCondition extends LitElement {
conditionName
)
)}
${this._renderDurationPrimingInfo()}
`;
}
@@ -472,6 +513,105 @@ export class HaPlatformCondition extends LitElement {
}
}
private _renderDurationPrimingInfo() {
const forValue = this.condition.options?.for;
// Priming only happens for entity conditions that have a `for:` duration.
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target
) {
return nothing;
}
if (this._targetHasUnrecordedEntity) {
return html`<ha-alert alert-type="info" class="priming-info">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
)}
</ha-alert>`;
}
if (this._durationExceedsLookback(forValue)) {
return html`<ha-alert alert-type="info" class="priming-info">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
)}
</ha-alert>`;
}
return nothing;
}
private _durationExceedsLookback(forValue: unknown): boolean {
const duration = createDurationData(
forValue as string | number | ForDict | undefined
);
if (!duration) {
return false;
}
const seconds =
(duration.days || 0) * 86400 +
(duration.hours || 0) * 3600 +
(duration.minutes || 0) * 60 +
(duration.seconds || 0) +
(duration.milliseconds || 0) / 1000;
return seconds > MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600;
}
private async _updateDurationPrimingInfo(): Promise<void> {
const forValue = this.condition.options?.for;
const target = this.condition.target;
// Recording status only matters for an entity condition that has both a
// target and a `for:` duration.
const token = ++this._recordingCheckToken;
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target ||
!target ||
!this.hass.config.components.includes("recorder")
) {
this._targetHasUnrecordedEntity = false;
return;
}
try {
const { referenced_entities } = await extractFromTarget(
this.hass.callWS,
target
);
// Ignore if a newer check superseded this one.
if (token !== this._recordingCheckToken) {
return;
}
if (!referenced_entities.length) {
this._targetHasUnrecordedEntity = false;
return;
}
const recordingDisabled = await Promise.all(
referenced_entities.map((entityId) =>
getRecordedEntity(this.hass, entityId)
.then((options) => options.recording_disabled_by !== null)
// Unknown entity or command unavailable on older cores: don't warn.
.catch(() => false)
)
);
if (token !== this._recordingCheckToken) {
return;
}
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
} catch (_err) {
// Target resolution failed; fall back to no warning rather than guessing.
if (token === this._recordingCheckToken) {
this._targetHasUnrecordedEntity = false;
}
}
}
static styles = css`
:host {
display: block;
@@ -527,6 +667,10 @@ export class HaPlatformCondition extends LitElement {
.clickable {
cursor: pointer;
}
.priming-info {
display: block;
margin: var(--ha-space-2) var(--ha-space-4) 0;
}
`;
}
@@ -269,9 +269,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelLookup!.get(lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
.map((lbl) => labelLookup!.get(lbl)!)
.filter(Boolean);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
automation.entity_id
@@ -313,7 +313,7 @@ class HaBackupConfigData extends LitElement {
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.data.share_folder_desc"
"ui.panel.config.backup.data.share_folder_description"
)}
</span>
<ha-switch
@@ -231,7 +231,6 @@ export class EntitySettingsHelperTab extends LitElement {
}
.form {
padding: 20px 24px;
z-index: 0;
}
.buttons {
box-sizing: border-box;
@@ -239,9 +238,6 @@ export class EntitySettingsHelperTab extends LitElement {
justify-content: space-between;
padding: 16px;
background-color: var(--mdc-theme-surface, #fff);
position: sticky;
bottom: 0px;
z-index: 1;
}
.error {
color: var(--error-color);
@@ -688,9 +688,9 @@ export class HaConfigEntities extends LitElement {
}
const labels = labelReg && entry?.labels;
const labelsEntries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const labelsEntries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
@@ -552,9 +552,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
const entityRegEntry =
entityRegistryByEntityId(entityReg)[item.entity_id];
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const category = entityRegEntry?.categories.helpers;
const deviceId = entityRegEntry?.device_id;
const areaId =
@@ -244,9 +244,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
);
const category = entityRegEntry?.categories.scene;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
scene.entity_id
+3 -3
View File
@@ -250,9 +250,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
);
const category = entityRegEntry?.categories.script;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
script.entity_id
+10 -6
View File
@@ -312,8 +312,7 @@
"start": "Start",
"pause": "Pause",
"cancel": "Cancel",
"finish": "Finish",
"set": "Set"
"finish": "Finish"
}
},
"vacuum": {
@@ -2067,7 +2066,7 @@
},
"restart": {
"heading": "Restart Home Assistant",
"more_options": "More options",
"advanced_options": "Advanced options",
"backup_in_progress": "A backup is currently being created. The action will automatically proceed once the backup process is complete.",
"upload_in_progress": "A backup upload is currently in progress. The action will automatically proceed once the upload process is complete.",
"restore_in_progress": "A backup restore is currently in progress. The action will automatically proceed once the restore process is complete.",
@@ -2684,7 +2683,7 @@
},
"developer_tools": {
"main": "Developer tools",
"secondary": "Tools to inspect and debug your system"
"secondary": "Advanced tools for inspecting and debugging your system"
},
"about": {
"main": "About",
@@ -2836,8 +2835,9 @@
"error_loading": "Error loading apps",
"info": {
"what_is_an_app": "What is an app?",
"what_is_an_app_description": "Apps run alongside Home Assistant and provide extra features like network monitoring, code editors, database management, and more.",
"what_is_an_app_description": "Apps allow you to extend the functionality around Home Assistant by installing additional applications. They run alongside Home Assistant and provide extra features like network monitoring, code editors, database management, and more.",
"why_not_available": "Why you see this page instead of an app store",
"why_not_available_description": "Apps are only available if you've used the Home Assistant Operating System installation method. If you installed Home Assistant using any other method then you cannot use apps. Often you can achieve the same result manually, refer to the documentation by the vendor of the application you'd like to install.",
"installation_hint": "Apps require the Home Assistant Operating System. Learn more about installation methods in the documentation.",
"learn_more": "Learn more",
"dismiss": "Don't show again"
@@ -3437,7 +3437,7 @@
"media": "Media",
"media_description": "For example, camera recordings.",
"share_folder": "Share folder",
"share_folder_desc": "Folder that is often used by apps for custom or older configurations.",
"share_folder_description": "Folder that is often used by apps for advanced or older configurations.",
"local_apps": "Local apps folder",
"local_apps_description": "Folder that contains the data of your local apps.",
"apps": "Apps",
@@ -5485,6 +5485,10 @@
"invalid_condition": "Invalid condition configuration",
"validation_failed": "Condition validation failed",
"test_failed": "Error occurred while testing condition",
"duration_priming": {
"entity_not_recorded": "One or more of the selected entities aren''t being recorded, so their history can''t be used. After a restart or reload, this condition only becomes true once they''ve been in the matching state for the full duration.",
"history_capped": "Only the last {hours} hours of history are checked. For longer durations, after a restart or reload this condition only becomes true once the entities have been in the matching state for the full duration."
},
"duplicate": "[%key:ui::common::duplicate%]",
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
-45
View File
@@ -1,45 +0,0 @@
import { assert, describe, it } from "vitest";
import { timerDurationData } from "../../src/data/timer";
describe("timerDurationData", () => {
it("derives the input prefill from the configured duration", () => {
assert.deepEqual(
timerDurationData({
state: "idle",
attributes: {
duration: "0:03:00",
remaining: "0:03:00",
},
} as any),
{ hours: 0, minutes: 3, seconds: 0, milliseconds: 0 }
);
});
it("uses the configured duration, not the remaining time, when running", () => {
assert.deepEqual(
timerDurationData({
state: "active",
attributes: {
duration: "2:00:00",
remaining: "1:30:00",
finishes_at: "2018-01-17T16:16:35Z",
},
} as any),
{ hours: 2, minutes: 0, seconds: 0, milliseconds: 0 }
);
});
it("handles durations beyond an hour", () => {
assert.deepEqual(
timerDurationData({
state: "idle",
attributes: {
duration: "3:30:00",
remaining: "3:30:00",
},
} as any),
{ hours: 3, minutes: 30, seconds: 0, milliseconds: 0 }
);
});
});
-251
View File
@@ -1,251 +0,0 @@
/**
* E2E tests for the HA test app (port 8095).
*
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The test app is built with __DEMO__=true which enables hash-based routing.
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
// Scenario selection uses query params: /?scenario=foo (always at root).
/** Navigate to a panel (hash routing) and wait for app to initialize. */
async function goToPanel(page: Page, path: string) {
// Paths starting with /? are root-level (scenario selection); panel paths
// need to use hash routing (/#/panelname).
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await page.waitForSelector("ha-test", { state: "attached" });
// Wait for the app to finish initialising (hassConnected sets panels)
await page.waitForFunction(() => Boolean((window as any).__mockHass));
}
// ---------------------------------------------------------------------------
// App shell
// ---------------------------------------------------------------------------
test.describe("App shell", () => {
test("page loads and ha-test element mounts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/");
await expect(page.locator("ha-test")).toBeAttached();
expect(errors).toHaveLength(0);
});
test("sidebar renders with expected panels", async ({ page }) => {
await goToPanel(page, "/lovelace");
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
for (const urlPath of ["lovelace", "energy", "history"]) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
)
).toBeAttached();
}
// Config has its own special element with id="sidebar-config"
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
)
).toBeAttached();
});
test("non-admin user does NOT see config panel in sidebar", async ({
page,
}) => {
// Navigate to a panel route so the sidebar actually renders, then apply
// the non-admin scenario via query param.
await goToPanel(page, "/?scenario=non-admin#/lovelace");
// Wait for the sidebar to mount before asserting on its contents.
await expect(
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
).toBeAttached({ timeout: SHELL_TIMEOUT });
// Config panel is adminOnly — should not appear for non-admin.
const configLink = page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
);
await expect(configLink).not.toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Panel navigation
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
test("navigates to lovelace dashboard", async ({ page }) => {
await goToPanel(page, "/lovelace");
await expect(
page.locator("ha-panel-lovelace, hui-root").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to energy panel", async ({ page }) => {
await goToPanel(page, "/energy");
await expect(
page.locator("ha-panel-energy, energy-view").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to history panel", async ({ page }) => {
await goToPanel(page, "/history");
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("navigates to developer-tools panel", async ({ page }) => {
// Since 2026.2 developer-tools is part of the config panel
await goToPanel(page, "/config/developer-tools");
await expect(
page.locator("ha-panel-config, developer-tools-main").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
});
test("navigates to profile panel", async ({ page }) => {
await goToPanel(page, "/profile");
await expect(
page.locator("ha-panel-profile, ha-config-user-profile").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// Lovelace
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
await expect(page.locator("hui-card, hui-tile-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test("admin user sees edit button", async ({ page }) => {
await goToPanel(page, "/lovelace");
// The edit FAB / menu button is present for admins
await expect(
page.locator("[data-testid='edit-mode-button'], ha-menu-button")
).toBeAttached({ timeout: QUICK_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
test("opens more-info dialog for a light entity", async ({ page }) => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
// Fire the standard hass-more-info event from the app root. The HA shell
// listens for this and opens ha-more-info-dialog via its dialog manager.
await page.evaluate(() => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light" },
bubbles: true,
composed: true,
})
);
});
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
// Confirm it actually rendered our entity, not a generic empty dialog.
await expect(dialog.locator("span.title")).toContainText("Test Light", {
timeout: QUICK_TIMEOUT,
});
});
});
// ---------------------------------------------------------------------------
// Theming
// ---------------------------------------------------------------------------
test.describe("Theming", () => {
test("dark theme sets darkMode flag", async ({ page }) => {
await goToPanel(page, "/?scenario=dark-theme#/lovelace");
// The dark-theme scenario sets selectedTheme.dark = true, which causes
// _applyTheme() to set themes.darkMode = true on the element.
await page.waitForFunction(
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
{ timeout: QUICK_TIMEOUT }
);
});
test("custom theme applies CSS variables", async ({ page }) => {
await goToPanel(page, "/?scenario=custom-theme#/lovelace");
// The custom-theme scenario sets --primary-color to #e91e63. Wait until
// _applyTheme has propagated the value to <html> before reading it — the
// scenario fires before hassConnected, but the variable lands on :root in
// the same tick mockTheme is called.
await page.waitForFunction(
() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim() !== "",
{ timeout: QUICK_TIMEOUT }
);
const primaryColor = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim()
);
// Compare normalised — some browsers serialise as rgb().
expect(primaryColor.toLowerCase()).toMatch(
/#e91e63|rgb\(233,\s*30,\s*99\)/
);
});
});
// ---------------------------------------------------------------------------
// Config panel
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
test("config panel loads without JS errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/config");
await expect(
page.locator("ha-panel-config, ha-config-dashboard").first()
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
// Filter known pre-existing errors from vendor code
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
});
-1
View File
@@ -1 +0,0 @@
import "./ha-test";
-53
View File
@@ -1,53 +0,0 @@
import type { Panels } from "../../../../src/types";
export const e2eTestPanels: Panels = {
lovelace: {
component_name: "lovelace",
icon: "mdi:view-dashboard",
title: "home",
config: { mode: "storage" },
url_path: "lovelace",
},
map: {
component_name: "lovelace",
icon: "mdi:tooltip-account",
title: "map",
config: { mode: "storage" },
url_path: "map",
},
energy: {
component_name: "energy",
icon: "mdi:lightning-bolt",
title: "energy",
config: null,
url_path: "energy",
},
history: {
component_name: "history",
icon: "mdi:chart-box",
title: "history",
config: null,
url_path: "history",
},
config: {
component_name: "config",
icon: "mdi:cog",
title: "config",
config: null,
url_path: "config",
},
profile: {
component_name: "profile",
icon: null,
title: null,
config: null,
url_path: "profile",
},
"developer-tools": {
component_name: "developer-tools",
icon: "mdi:hammer",
title: "developer_tools",
config: null,
url_path: "developer-tools",
},
};
-137
View File
@@ -1,137 +0,0 @@
import { customElement } from "lit/decorators";
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
import { navigate } from "../../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../../../src/types";
import { demoSections } from "../../../../demo/src/configs/sections";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockAssist } from "../../../../demo/src/stubs/assist";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import { mockCloud } from "../../../../demo/src/stubs/cloud";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockEvents } from "../../../../demo/src/stubs/events";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
import { mockPersistentNotification } from "../../../../demo/src/stubs/persistent_notification";
import { mockRecorder } from "../../../../demo/src/stubs/recorder";
import { mockSensor } from "../../../../demo/src/stubs/sensor";
import { mockSystemLog } from "../../../../demo/src/stubs/system_log";
import { mockTemplate } from "../../../../demo/src/stubs/template";
import { mockTodo } from "../../../../demo/src/stubs/todo";
import { mockTranslations } from "../../../../demo/src/stubs/translations";
import { mockUpdate } from "../../../../demo/src/stubs/update";
import { e2eTestPanels } from "./ha-test-panels";
import { scenarios } from "./scenarios";
declare global {
interface Window {
__mockHass: MockHomeAssistant;
}
}
@customElement("ha-test")
export class HaTest extends HomeAssistantAppEl {
protected async _initializeHass() {
const scenarioName =
new URLSearchParams(window.location.search).get("scenario") ?? "default";
const scenario = Object.prototype.hasOwnProperty.call(
scenarios,
scenarioName
)
? scenarios[scenarioName as keyof typeof scenarios]
: scenarios.default;
const initial: Partial<MockHomeAssistant> = {
// Use the full panel map (history + config + developer-tools enabled)
panels: e2eTestPanels,
panelUrl: (() => {
const path = window.location.pathname;
const dividerPos = path.indexOf("/", 1);
return dividerPos === -1
? path.substring(1)
: path.substring(1, dividerPos);
})(),
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
this._updateHass(hassUpdate),
};
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
() => this.hass!.localize
);
// Register all stubs
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
mockSensor(hass);
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockEnergy(hass);
mockUpdate(hass);
mockCloud(hass);
mockAssist(hass);
mockAreaRegistry(hass);
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
mockPersistentNotification(hass);
// Load default entities from the sections config
hass.addEntities(energyEntities());
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
}
);
// Apply scenario customisations (may add entities, change user, set theme,
// navigate to a panel, etc.)
await scenario(hass);
// Expose mock handle for Playwright tests to call imperatively
window.__mockHass = hass;
// SPA navigation
document.body.addEventListener(
"click",
(e) => {
const href = isNavigationClick(e);
if (!href) return;
e.preventDefault();
navigate(href);
},
{ capture: true }
);
this.hassConnected();
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-test": HaTest;
}
}
-41
View File
@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant E2E Test App</title>
<%= renderTemplate("../../../../../src/html/_header.html.template") %>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
height: 100vh;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<ha-test></ha-test>
<%= renderTemplate("../../../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../../../src/html/_preload_roboto.html.template") %>
<%= renderTemplate("../../../../../src/html/_script_loader.html.template") %>
</body>
</html>
-69
View File
@@ -1,69 +0,0 @@
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
// ── Individual scenarios ───────────────────────────────────────────────────
const defaultScenario: Scenario = async (_hass) => {
// Default: admin user, light theme — nothing extra to do, ha-test.ts sets
// everything up already.
};
const nonAdminScenario: Scenario = async (hass) => {
hass.updateHass({
user: {
...hass.user!,
is_admin: false,
is_owner: false,
},
});
};
const darkThemeScenario: Scenario = async (hass) => {
// Force dark mode by setting selectedTheme.dark = true.
// _applyTheme() reads selectedTheme.dark to determine darkMode; setting
// themes.darkMode directly gets overwritten when hassConnected() fires.
hass.updateHass({
selectedTheme: {
theme: hass.selectedTheme?.theme ?? "default",
dark: true,
},
});
};
const customThemeScenario: Scenario = async (hass) => {
hass.mockTheme({
"primary-color": "#e91e63",
"accent-color": "#ff5722",
});
};
const lightMoreInfoScenario: Scenario = async (hass) => {
// Make sure we have a light entity available (sections config adds them, but
// this ensures it exists synchronously for tests that load mid-init).
hass.addEntities([
{
entity_id: "light.test_light",
state: "on",
attributes: {
friendly_name: "Test Light",
supported_features: 44,
supported_color_modes: ["brightness", "color_temp", "xy"],
color_mode: "brightness",
brightness: 200,
min_mireds: 153,
max_mireds: 500,
},
},
]);
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
default: defaultScenario,
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"light-more-info": lightMoreInfoScenario,
};
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env node
// Collects blob reports from each suite into a single staging directory so
// `playwright merge-reports` can consume them from one path.
//
// Usage: node test/e2e/collect-blob-reports.mjs
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
const dest = "test/e2e/reports/blob";
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const suite of ["demo", "app", "gallery"]) {
const src = `test/e2e/reports/${suite}`;
let files;
try {
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
} catch {
// Suite report directory doesn't exist (e.g. job was skipped or failed
// before uploading). Skip gracefully.
process.stderr.write(
`Warning: no blob reports found for suite "${suite}" (${src} missing), skipping.\n`
);
continue;
}
for (const file of files) {
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
}
}
-209
View File
@@ -1,209 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
NAVIGATION_TIMEOUT,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
appErrors as filterAppErrors,
waitForOrSkip,
} from "./helpers";
// BrowserStack mobile platforms only allow a single browser context per
// session. Using serial mode + a shared page (created once in beforeAll)
// avoids Playwright spinning up a new context for each test.
test.describe.configure({ mode: "serial" });
test.describe("Home Assistant Demo", () => {
// Collect JS errors during each test so we can assert no unexpected crashes.
let pageErrors: Error[] = [];
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
// BrowserStack mobile pre-creates a single context and page.
// Re-use them instead of calling browser.newContext() which would trigger
// "Only one browser context is allowed" on mobile devices.
const existingContexts = browser.contexts();
const context =
existingContexts.length > 0
? existingContexts[0]
: await browser.newContext();
const existingPages = context.pages();
sharedPage =
existingPages.length > 0 ? existingPages[0] : await context.newPage();
});
test.afterAll(async () => {
// Do not close the context — BrowserStack manages it.
// Just navigate away to a blank page to clean up.
await sharedPage.goto("about:blank").catch(() => {
// Ignore errors if the page/session is already gone.
});
});
test.beforeEach(async () => {
pageErrors = [];
sharedPage.removeAllListeners("pageerror");
sharedPage.on("pageerror", (err) => pageErrors.push(err));
await sharedPage.goto("/");
});
function appErrors() {
return filterAppErrors(pageErrors);
}
// ── 1. Page loads ──────────────────────────────────────────────────────────
test("page loads and ha-demo mounts without JS errors", async () => {
const page = sharedPage;
// The custom element is present in the document
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
// The launch screen should disappear once the app is ready
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
// No unhandled JS exceptions (excluding infra tunnel errors)
expect(appErrors()).toHaveLength(0);
});
// ── 2. Dashboard renders ───────────────────────────────────────────────────
test("dashboard renders Lovelace cards", async () => {
const page = sharedPage;
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 waitForOrSkip(
page,
cardSelector,
"attached",
PANEL_TIMEOUT,
pageErrors
);
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
});
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
test("sidebar navigation changes the active panel", async () => {
const page = sharedPage;
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
// inside a modal drawer that is closed by default. Open it first via
// the ha-menu-button in the top app-bar.
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
await waitForOrSkip(
page,
"ha-sidebar",
"visible",
SHELL_TIMEOUT,
pageErrors
);
} else {
await waitForOrSkip(
page,
"ha-sidebar",
"attached",
NAVIGATION_TIMEOUT,
pageErrors
);
}
const candidatePanels = ["map", "logbook", "history", "config"];
const panelSelector = candidatePanels
.map((p) => `#sidebar-panel-${p}`)
.join(", ");
await waitForOrSkip(
page,
panelSelector,
"visible",
SHELL_TIMEOUT,
pageErrors
);
let clicked = false;
for (const panel of candidatePanels) {
const navItem = page.locator(`#sidebar-panel-${panel}`);
// eslint-disable-next-line no-await-in-loop
const visible = await navItem.isVisible().catch(() => false);
if (visible) {
// eslint-disable-next-line no-await-in-loop
await navItem.click();
// eslint-disable-next-line no-await-in-loop
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
timeout: SHELL_TIMEOUT,
});
clicked = true;
break;
}
}
expect(clicked, "No known sidebar panel was found to click").toBe(true);
expect(appErrors()).toHaveLength(0);
});
// ── 4. More info dialog ───────────────────────────────────────────────────
test("clicking an entity card opens the more-info dialog", async () => {
const page = sharedPage;
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
// 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 waitForOrSkip(
page,
cardSelector,
"visible",
NAVIGATION_TIMEOUT,
pageErrors
);
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);
});
});
-381
View File
@@ -1,381 +0,0 @@
/**
* E2E tests for the HA gallery (port 8100).
*
* Each component page is tested by navigating to its hash and asserting that
* the demo content renders without JS errors and the page element is visible.
*
* Run with:
* yarn test:e2e:gallery
*/
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 gallery feeds demos a synchronous mock `hass`, but migrated
// components now read `localize`/formatters from Lit context, which resolves
// asynchronously — so a demo can render one frame before the context lands and
// throw a transient "undefined" error during init. These don't prevent the
// demo from rendering (the toBeAttached check above still has to pass), and
// they're timing-dependent, so we filter the whole init-error family here.
const IGNORED_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// localize consumed from context before it resolves (`this.localize` /
// `this._localize is not a function`, or `hass.localize` read as undefined).
/_?localize is not a function/,
/Cannot read properties of undefined \(reading 'localize'\)/,
// Formatters consumed from context before it resolves.
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
// hass API methods consumed from context before it resolves (e.g. the
// update demo fetches release notes via callWS during init).
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
// locale fields read before the mock locale is wired up.
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
// 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: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/");
await expect(page.locator("ha-gallery")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
test("sidebar renders navigation links", async ({ page }) => {
await page.goto("/");
await page.waitForSelector("ha-gallery", { state: "attached" });
// The gallery drawer sidebar is inside ha-gallery's shadow root
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
});
// ---------------------------------------------------------------------------
// Component pages
// ---------------------------------------------------------------------------
const componentPages: { name: string; selector: string }[] = [
{ name: "ha-alert", selector: "demo-components-ha-alert" },
{ name: "ha-badge", selector: "demo-components-ha-badge" },
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",
selector: "demo-components-ha-control-circular-slider",
},
{
name: "ha-control-number-buttons",
selector: "demo-components-ha-control-number-buttons",
},
{
name: "ha-control-select-menu",
selector: "demo-components-ha-control-select-menu",
},
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
{
name: "ha-expansion-panel",
selector: "demo-components-ha-expansion-panel",
},
{ name: "ha-faded", selector: "demo-components-ha-faded" },
{ name: "ha-form", selector: "demo-components-ha-form" },
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
{
name: "ha-hs-color-picker",
selector: "demo-components-ha-hs-color-picker",
},
{ name: "ha-input", selector: "demo-components-ha-input" },
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
{ name: "ha-list", selector: "demo-components-ha-list" },
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
{
name: "ha-progress-button",
selector: "demo-components-ha-progress-button",
},
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
{ name: "ha-selector", selector: "demo-components-ha-selector" },
{ name: "ha-slider", selector: "demo-components-ha-slider" },
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
{ name: "ha-switch", selector: "demo-components-ha-switch" },
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
{ name: "ha-tip", selector: "demo-components-ha-tip" },
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
{
name: "ha-adaptive-dialog",
selector: "demo-components-ha-adaptive-dialog",
},
{
name: "ha-adaptive-popover",
selector: "demo-components-ha-adaptive-popover",
},
];
test.describe("Components", () => {
for (const { name, selector } of componentPages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `components/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// More-info pages
// ---------------------------------------------------------------------------
const moreInfoPages: { name: string; selector: string }[] = [
{ name: "light", selector: "demo-more-info-light" },
{ name: "climate", selector: "demo-more-info-climate" },
{ name: "cover", selector: "demo-more-info-cover" },
{ name: "fan", selector: "demo-more-info-fan" },
{ name: "humidifier", selector: "demo-more-info-humidifier" },
{ name: "input-number", selector: "demo-more-info-input-number" },
{ name: "input-text", selector: "demo-more-info-input-text" },
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
{ name: "lock", selector: "demo-more-info-lock" },
{ name: "media-player", selector: "demo-more-info-media-player" },
{ name: "number", selector: "demo-more-info-number" },
{ name: "scene", selector: "demo-more-info-scene" },
{ name: "timer", selector: "demo-more-info-timer" },
{ name: "update", selector: "demo-more-info-update" },
{ name: "vacuum", selector: "demo-more-info-vacuum" },
{ name: "water-heater", selector: "demo-more-info-water-heater" },
];
test.describe("More-info dialogs", () => {
for (const { name, selector } of moreInfoPages) {
test(`more-info ${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `more-info/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Lovelace card pages
// ---------------------------------------------------------------------------
const lovelacePages: { name: string; selector: string }[] = [
{ name: "area-card", selector: "demo-lovelace-area-card" },
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
{
name: "grid-and-stack-card",
selector: "demo-lovelace-grid-and-stack-card",
},
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
{ name: "light-card", selector: "demo-lovelace-light-card" },
{ name: "map-card", selector: "demo-lovelace-map-card" },
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
{
name: "picture-elements-card",
selector: "demo-lovelace-picture-elements-card",
},
{
name: "picture-entity-card",
selector: "demo-lovelace-picture-entity-card",
},
{
name: "picture-glance-card",
selector: "demo-lovelace-picture-glance-card",
},
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
];
test.describe("Lovelace cards", () => {
for (const { name, selector } of lovelacePages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `lovelace/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Specific interaction tests
// ---------------------------------------------------------------------------
test.describe("Component interactions", () => {
test("ha-alert renders all four types", async ({ page }) => {
await goToGalleryPage(page, "components/ha-alert");
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// The demo uses property binding (.alertType) not attribute binding,
// so we verify that multiple ha-alert elements are present.
const alerts = demo.locator("ha-alert");
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
// There should be at least 4 alerts (one per type)
await expect(alerts)
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
.catch(async () => {
// If not exactly 4, just verify there are some (demo may include more)
const count = await alerts.count();
expect(count).toBeGreaterThanOrEqual(4);
});
});
test("ha-button renders primary action button", async ({ page }) => {
await goToGalleryPage(page, "components/ha-button");
const demo = page.locator("ha-gallery >> demo-components-ha-button");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-control-slider can be found in DOM", async ({ page }) => {
await goToGalleryPage(page, "components/ha-control-slider");
const demo = page.locator(
"ha-gallery >> demo-components-ha-control-slider"
);
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-form renders schema-driven fields", async ({ page }) => {
await goToGalleryPage(page, "components/ha-form");
const demo = page.locator("ha-gallery >> demo-components-ha-form");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-form").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
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 }) => {
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 }) => {
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 });
});
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 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 }) => {
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 — 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: any) => el.checked === true);
await switchEl.click();
await expect
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
timeout: QUICK_TIMEOUT,
})
.toBe(!before);
});
});
-88
View File
@@ -1,88 +0,0 @@
/**
* Shared helpers and constants for Playwright e2e suites.
*/
import { test, type Page } from "@playwright/test";
// ── Timeouts ────────────────────────────────────────────────────────────────
// Centralised so tweaks don't require search-and-replace across spec files.
// Values are in milliseconds.
/** Single fast UI assertion (element should already be there). */
export const QUICK_TIMEOUT = 10_000;
/** Standard app-shell readiness wait. */
export const SHELL_TIMEOUT = 15_000;
/** Heavier panel/dialog loads that involve dynamic imports. */
export const PANEL_TIMEOUT = 20_000;
/** First navigation / cold-cache loads on slow runners. */
export const NAVIGATION_TIMEOUT = 30_000;
// ── Error filtering ─────────────────────────────────────────────────────────
/**
* BrowserStack tunnel sometimes fails to deliver dynamic-import chunks on
* mobile/iOS combos. These are infrastructure errors, not app bugs.
*/
export const DYNAMIC_IMPORT_ERROR =
/error loading dynamically imported module|Importing a module script failed/i;
/**
* 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
* - Dynamic-import infra failures (see DYNAMIC_IMPORT_ERROR above)
*/
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") &&
!DYNAMIC_IMPORT_ERROR.test(msg)
);
}
/**
* Wait for `selector` and skip the test (visibly) if the wait fails due to
* a known BrowserStack tunnel infrastructure issue. Re-throws any other error
* so genuine app bugs still surface as test failures.
*
* The BrowserStack iOS WebKit driver occasionally raises a CDP-level
* "Internal error" from `page.locator.waitFor` these are tunnel/driver
* issues, not app bugs. We narrow the substring match to the specific
* Playwright error message so we don't accidentally swallow real "Internal
* error" exceptions thrown by application code.
*/
export async function waitForOrSkip(
page: Page,
selector: string,
state: "attached" | "visible" = "attached",
timeout = NAVIGATION_TIMEOUT,
pageErrors: Error[] = []
) {
try {
await page.locator(selector).first().waitFor({ state, timeout });
return "ok" as const;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// A dynamic-import network failure (recorded via pageerror) means the
// JS chunk this test depends on never loaded — skip rather than fail.
if (pageErrors.some((e) => DYNAMIC_IMPORT_ERROR.test(e.message))) {
test.skip(true, `dynamic-import infra failure for "${selector}"`);
}
// BrowserStack iOS WebKit raises `locator.waitFor: Internal error` from
// the CDP layer. Match the exact prefix so app-thrown "Internal error"
// strings don't get masked.
if (/locator\.waitFor:.*Internal error/i.test(msg)) {
test.skip(
true,
`BrowserStack iOS WebKit CDP "Internal error" for "${selector}"`
);
}
throw err;
}
}
-55
View File
@@ -1,55 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const APP_PORT = 8095;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const APP_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${APP_PORT}`
: `http://localhost:${APP_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const APP_LOCAL_URL = `http://localhost:${APP_PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "app.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
use: {
baseURL: APP_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
webServer: {
command: process.env.CI
? `npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`
: `./node_modules/.bin/gulp build-e2e-test-app && npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`,
url: APP_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
-70
View File
@@ -1,70 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
// Port 8090 matches the `develop_demo` dev server (rspack-dev-server-demo).
// This means running `demo/script/develop_demo` and then `yarn test:e2e:local`
// works out of the box locally — Playwright will reuse the already-running
// server instead of starting a new one.
// In CI we serve the pre-built demo/dist on the same port.
const DEMO_PORT = 8090;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const DEMO_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${DEMO_PORT}`
: `http://localhost:${DEMO_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const DEMO_LOCAL_URL = `http://localhost:${DEMO_PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "demo.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
use: {
baseURL: DEMO_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
// Serve the demo for tests.
// - Locally: if `develop_demo` is already running on port 8090, Playwright
// reuses it. Otherwise it builds demo/dist and serves it.
// Running `develop_demo` first is the recommended local workflow.
// - In CI: demo/dist is downloaded from the build-demo artifact before this
// runs, so we skip the build and go straight to serving.
webServer: {
command: process.env.CI
? `npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`
: `./node_modules/.bin/gulp build-demo && npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`,
url: DEMO_LOCAL_URL,
// Reuse the develop_demo dev server if it is already running locally.
reuseExistingServer: !process.env.CI,
// Allow up to 5 minutes locally for the demo build + serve startup.
timeout: process.env.CI ? 30_000 : 300_000,
// Run from the repo root so `demo/dist` resolves correctly.
// This config lives at test/e2e/, so two levels up is the repo root.
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
-55
View File
@@ -1,55 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const GALLERY_PORT = 8100;
// When running via the BrowserStack SDK the tunnel maps bs-local.com to
// localhost, so the remote browsers must use bs-local.com as the host.
const GALLERY_BASE_URL = process.env.BROWSERSTACK_AUTOMATION
? `http://bs-local.com:${GALLERY_PORT}`
: `http://localhost:${GALLERY_PORT}`;
// webServer healthcheck always talks to the local process, not via the tunnel.
const GALLERY_LOCAL_URL = `http://localhost:${GALLERY_PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "gallery.spec.ts",
timeout: 60_000,
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
use: {
baseURL: GALLERY_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
],
webServer: {
command: process.env.CI
? `npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`
: `./node_modules/.bin/gulp build-gallery && npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`,
url: GALLERY_LOCAL_URL,
reuseExistingServer: !process.env.CI,
timeout: process.env.CI ? 30_000 : 600_000,
cwd:
process.env.GITHUB_WORKSPACE ??
new URL("../..", import.meta.url).pathname,
stdout: "pipe",
stderr: "pipe",
},
});
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
reporter: [
["html", { outputFolder: "reports/combined", open: "never" }],
["json", { outputFile: "reports/combined/results.json" }],
],
});
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env node
// Runs each e2e suite (demo, app, gallery) regardless of individual failures,
// then collects and merges blob reports and exits with a non-zero code if any
// suite failed.
//
// Usage: node test/e2e/run-suites.mjs <suite> [<suite> ...]
// Where <suite> matches a test:e2e:<suite> script in package.json,
// e.g. "demo", "app", "gallery", "demo:browserstack", etc.
//
// Using ; or running suites independently avoids the && short-circuit problem
// where a failing suite skips the remaining suites and their blob reports.
import { execFileSync } from "child_process";
const suites = process.argv.slice(2);
if (!suites.length) {
process.stderr.write("Usage: run-suites.mjs <suite> [<suite> ...]\n");
process.exit(1);
}
const failures = [];
for (const suite of suites) {
process.stdout.write(`\n--- Running suite: test:e2e:${suite} ---\n`);
try {
execFileSync("yarn", [`test:e2e:${suite}`], { stdio: "inherit" });
} catch {
failures.push(suite);
}
}
// Collect and merge blob reports regardless of suite outcomes.
// (Skipped for browserstack suites — BrowserStack dashboard is the report.)
const isBrowserStack = suites.some((s) => s.includes("browserstack"));
if (!isBrowserStack) {
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
}
if (failures.length) {
process.stderr.write(
`\nFailed suites: ${failures.map((s) => `test:e2e:${s}`).join(", ")}\n`
);
process.exit(1);
}
-15
View File
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
// e2e tests run via Playwright's Node.js runner, not the browser bundler
"lib": ["ES2021"],
// Playwright's types use modern module resolution
"moduleResolution": "bundler",
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["./**/*.ts"],
// Clear the exclude from the root tsconfig so we include our own files
"exclude": []
}
+1 -2
View File
@@ -1,10 +1,9 @@
import { defineConfig, configDefaults } from "vitest/config";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
exclude: [...configDefaults.exclude, "test/e2e/**"],
environment: "jsdom", // to run in browser-like environment
env: {
TZ: "Etc/UTC",
+1 -3
View File
@@ -157,7 +157,5 @@
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
"lit/directives/ref": ["./node_modules/lit/directives/ref.js"]
}
},
// Exclude e2e tests they have their own tsconfig that adds node types.
"exclude": ["test/e2e"]
}
}
+45 -2761
View File
File diff suppressed because it is too large Load Diff