Compare commits

..

2 Commits

Author SHA1 Message Date
Bram Kragten 418327d6fe Fix js-yaml v5 default import in gallery build script
js-yaml v5 removed the ESM default export. The gallery page gatherer
imported it as a default, which crashed the gulp resource build and
cascaded into the lint, test, and all build CI jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:49:06 +02:00
Bram Kragten e637c0c583 Update dependency js-yaml to v5
js-yaml v5 is a major rewrite with breaking API and behavior changes.
This migrates our usage so behavior matches the YAML 1.1 semantics of
the PyYAML backend (the previous v4 default).

- Replace removed `DEFAULT_SCHEMA` with `YAML11_SCHEMA`. v5's new load
  default is `CORE_SCHEMA` (YAML 1.2), which parses `on`/`off`/`yes`/`no`
  as strings and drops merge keys (`<<`). `YAML11_SCHEMA` preserves the
  boolean semantics and `!!merge` we (and the backend) rely on.
- Pin the bare `load(paste)` calls in the automation and script editors
  to `YAML11_SCHEMA` for the same reason.
- Port the custom `!secret` tag from the removed `Type`/`Schema.extend()`
  API to `defineScalarTag()` + `Schema.withTags()`.
- Drop the removed `dump()` option `quotingType` (the v5 default
  `quoteStyle: "auto"` quotes only when needed and round-trips safely).
- Remove `@types/js-yaml`; v5 ships its own TypeScript types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:33:36 +02:00
40 changed files with 443 additions and 1329 deletions
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
@@ -1,195 +0,0 @@
#!/usr/bin/env node
// Checks that a pull request follows the contribution standards: it must use the
// PR template, tick exactly one "Type of change" option, and describe the change.
// Labels and comments the PR when it does not, and fails the check so it blocks
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await checkStandards({ github, context, core });
export default async function checkPullRequestStandards({
github,
context,
core,
}) {
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;
let body = pr.body || "";
let previous;
do {
previous = body;
body = body.replace(/<!--[\s\S]*?-->/g, "");
} while (body !== previous);
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- ")}`);
}
@@ -1,58 +0,0 @@
#!/usr/bin/env node
// Restricts Task issues to organization members: closes and labels the issue with
// an explanatory comment when the author is not an org member. Invoked from the
// `check-authorization` job in .github/workflows/restrict-task-creation.yml via
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await checkTaskAuthorization({ github, context, core });
export default async function checkTaskAuthorization({
github,
context,
core,
}) {
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
core.info(`${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body:
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
}
+23 -8
View File
@@ -20,16 +20,31 @@ jobs:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
await checkBlockingLabels({ github, context, core });
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
+10 -6
View File
@@ -189,7 +189,7 @@ jobs:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
if: always()
permissions:
contents: read
pull-requests: write
@@ -229,12 +229,16 @@ jobs:
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report to PR
- name: Post report link to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { default: postReportComment } = await import(
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E tests failed\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,
});
+161 -9
View File
@@ -1,7 +1,7 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
@@ -23,16 +23,168 @@ jobs:
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
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- ")}`
);
await checkStandards({ github, context, core });
+41 -10
View File
@@ -36,21 +36,52 @@ jobs:
name: Check authorization
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await checkTaskAuthorization({ github, context, core });
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import yaml from "js-yaml";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
+1 -4
View File
@@ -53,7 +53,6 @@ const CONFIG_PANEL_COMMANDS = [
"config/scene/config",
"search/related",
"tag/list",
"assist_pipeline/",
];
@customElement("ha-demo")
@@ -66,9 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+4 -36
View File
@@ -1,43 +1,11 @@
import type { AssistPipeline } from "../../../src/data/assist_pipeline";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const pipelines: AssistPipeline[] = [
{
id: "01home_assistant_cloud",
name: "Home Assistant Cloud",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "cloud",
stt_language: "en-US",
tts_engine: "cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
{
id: "01local",
name: "Local",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.faster_whisper",
stt_language: "en",
tts_engine: "tts.piper",
tts_language: "en",
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
},
];
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns a cloud and a local pipeline so the
// voice assistants config panel shows configured assistants.
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines,
preferred_pipeline: "01home_assistant_cloud",
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
-2
View File
@@ -1,6 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { mockApplicationCredentials } from "./application_credentials";
import { mockAssist } from "./assist";
import { mockAutomation } from "./automation";
import { mockBackup } from "./backup";
import { mockBlueprint } from "./blueprint";
@@ -38,5 +37,4 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockScene(hass);
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
};
+4 -9
View File
@@ -2,27 +2,22 @@ import type { ExposeEntitySettings } from "../../../src/data/expose";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const exposedEntities: Record<string, ExposeEntitySettings> = {
"light.floor_lamp": {
"light.bed_light": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.living_room_spotlights": {
"light.ceiling_lights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": false,
},
"light.bar_lamp": {
"switch.decorative_lights": {
conversation: true,
"cloud.alexa": false,
"cloud.google_assistant": true,
},
"light.kitchen_spotlights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.outdoor_light": {
"climate.ecobee": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
-6
View File
@@ -240,12 +240,6 @@ export default tseslint.config(
globals: globals.node,
},
},
{
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
-91
View File
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -20,22 +19,6 @@ import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../../src/data/context";
import { updateHassGroups } from "../../src/data/context/updateContext";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
@@ -130,65 +113,6 @@ class HaGallery extends LitElement {
@state() private _drawerOpen = !this._narrow;
// Fallback Lit context providers for the whole gallery. The real app's root
// element provides these via `contextMixin`; here we mirror that so demos
// which render context-consuming components without setting up their own hass
// (e.g. bare component demos) still resolve `localize`, formatters, config,
// etc. instead of throwing during init. Demos that call `provideHass`
// register their own providers closer in the tree, which take precedence.
private _contextProviders = {
registries: new ContextProvider(this, { context: registriesContext }),
internationalization: new ContextProvider(this, {
context: internationalizationContext,
}),
api: new ContextProvider(this, { context: apiContext }),
connection: new ContextProvider(this, { context: connectionContext }),
ui: new ContextProvider(this, { context: uiContext }),
config: new ContextProvider(this, { context: configContext }),
formatters: new ContextProvider(this, { context: formattersContext }),
};
// The individual (non-grouped) contexts contextMixin also provides. Components
// such as ha-area-picker / ha-entity-picker consume these directly, so the
// fallback must cover them too.
private _singleContextProviders = {
states: new ContextProvider(this, { context: statesContext }),
services: new ContextProvider(this, { context: servicesContext }),
entities: new ContextProvider(this, { context: entitiesContext }),
devices: new ContextProvider(this, { context: devicesContext }),
areas: new ContextProvider(this, { context: areasContext }),
floors: new ContextProvider(this, { context: floorsContext }),
};
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
// the gallery hass propagate to consuming components.
const hass = this._galleryHass;
(
Object.keys(
this._contextProviders
) as (keyof typeof this._contextProviders)[]
).forEach((group) => {
const provider = this._contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
hass,
provider.value
)
);
});
(
Object.keys(
this._singleContextProviders
) as (keyof typeof this._singleContextProviders)[]
).forEach((key) => {
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
hass[key]
);
});
}
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
@@ -652,21 +576,6 @@ class HaGallery extends LitElement {
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
formatEntityState: (stateObj, stateValue) =>
(stateValue != null ? stateValue : stateObj.state) ?? "",
formatEntityStateToParts: (stateObj, stateValue) => [
{
type: "value",
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
},
],
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
} as unknown as HomeAssistant;
}
+28 -1
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -522,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -543,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+2 -3
View File
@@ -102,7 +102,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"js-yaml": "4.2.0",
"js-yaml": "5.0.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -144,7 +144,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.0",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
@@ -154,7 +154,6 @@
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260527.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -1,13 +1,4 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export default function durationToSeconds(duration: string): number {
const parts = duration.split(":").map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export const durationDataToSeconds = (duration: HaDurationData): number =>
(duration.days || 0) * 86400 +
(duration.hours || 0) * 3600 +
(duration.minutes || 0) * 60 +
(duration.seconds || 0) +
(duration.milliseconds || 0) / 1000;
+2 -3
View File
@@ -1,5 +1,5 @@
import type { Schema } from "js-yaml";
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import { dump, load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
export class HaYamlEditor extends LitElement {
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
@property({ attribute: false }) public defaultValue?: any;
@@ -70,7 +70,6 @@ export class HaYamlEditor extends LitElement {
this._yaml = !isEmpty(value)
? dump(value, {
schema: this.yamlSchema,
quotingType: '"',
noRefs: true,
})
: "";
+1 -8
View File
@@ -331,14 +331,7 @@ export interface AutomationElementGroupCollection {
export type AutomationElementGroup = Record<
string,
{
icon?: string;
members?: AutomationElementGroup;
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
// are bundled into this group instead of appearing as their own dynamic
// domain group.
domains?: string[];
}
{ icon?: string; members?: AutomationElementGroup }
>;
export type LegacyCondition =
+4 -9
View File
@@ -1,4 +1,4 @@
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import { mdiMapClock, mdiShape } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
@@ -9,14 +9,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time: {
icon: mdiClockOutline,
members: { time: {} },
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
helpers: {},
template: {},
-15
View File
@@ -153,21 +153,6 @@ export const getRecorderInfo = (conn: Connection) =>
type: "recorder/info",
});
export type EntityRecordingDisabler = "user";
export interface RecorderEntityOptions {
recording_disabled_by: EntityRecordingDisabler | null;
}
export const getRecorderEntityOptions = (
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<RecorderEntityOptions>({
type: "recorder/entity_options/get",
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
+6 -8
View File
@@ -1,4 +1,4 @@
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import { mdiMapClock, mdiShape } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
@@ -14,17 +14,15 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time: {
icon: mdiClockOutline,
time_location: {
icon: mdiMapClock,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
event: {},
geo_location: {},
+18 -53
View File
@@ -9,17 +9,11 @@ import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
@@ -103,15 +97,11 @@ export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false,
// Provide the grouped Lit contexts (registries, internationalization, api,
// connection, ui, config, formatters) that the real app's root element
// provides via `contextMixin`. On by default so that any standalone hass root
// (e.g. a gallery demo) automatically feeds context-consuming components the
// same way the real app does, instead of each demo wiring up a partial set by
// hand. Pass `false` for hosts that already provide these contexts themselves
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
// registering duplicate providers on the same element.
provideContexts = true
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
// that the real app's root element provides via `contextMixin`. Needed for
// gallery demos that render context-consuming components (e.g. the climate
// temperature control) without the full app shell.
provideContexts = false
): MockHomeAssistant => {
elements = ensureArray(elements);
// Can happen because we store sidebar, more info etc on hass.
@@ -138,46 +128,21 @@ export const provideHass = (
}
: undefined;
// The individual (non-grouped) contexts that contextMixin also provides.
// Components such as ha-area-picker / ha-entity-picker consume these directly
// (e.g. `Object.values(areas)`), so they must be provided alongside the
// grouped contexts or those components throw once they render.
const singleContextProviders = provideContexts
? {
states: new ContextProvider(baseEl(), { context: statesContext }),
services: new ContextProvider(baseEl(), { context: servicesContext }),
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
devices: new ContextProvider(baseEl(), { context: devicesContext }),
areas: new ContextProvider(baseEl(), { context: areasContext }),
floors: new ContextProvider(baseEl(), { context: floorsContext }),
}
: undefined;
const updateContextProviders = (newHass: HomeAssistant) => {
if (contextProviders) {
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
}
if (singleContextProviders) {
(
Object.keys(
singleContextProviders
) as (keyof typeof singleContextProviders)[]
).forEach((key) => {
(singleContextProviders[key] as ContextProvider<any>).setValue(
newHass[key]
);
});
if (!contextProviders) {
return;
}
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
};
const wsCommands = {};
@@ -1,5 +1,5 @@
import { mdiDotsVertical } from "@mdi/js";
import { DEFAULT_SCHEMA, Type } from "js-yaml";
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -47,12 +47,11 @@ const SUPPORTED_UI_TYPES = [
"schema",
];
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
new Type("!secret", {
kind: "scalar",
construct: (data) => `!secret ${data}`,
}),
]);
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -192,11 +192,6 @@ const DYNAMIC_KEYWORDS = [
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// Group keys surfaced as their own section in the "by target" tab because
// their elements have no target (time/calendar/schedule, sun). Picking one
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
@customElement("add-automation-element-dialog")
@@ -688,28 +683,13 @@ class DialogAddAutomationElement
.hass=${this.hass}
.value=${this._selectedTarget}
@value-changed=${this._handleTargetSelected}
@time-location-group-selected=${this
._handleTimeLocationGroupSelected}
.narrow=${this._narrow}
.timeLocationLabel=${this._getTimeLocationLabel(
automationElementType
)}
.timeLocationGroups=${this._getTimeLocationGroups(
automationElementType,
this.hass.localize,
automationElementType === "condition"
? this._conditionDescriptions
: this._triggerDescriptions
)}
.selectedGroup=${this._selectedGroup}
class=${classMap({
"ha-scrollbar": true,
hidden:
!!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
) ||
(this._narrow && !!this._selectedGroup),
hidden: !!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
),
})}
.manifests=${this._manifests}
></ha-automation-add-from-target>`
@@ -815,13 +795,12 @@ class DialogAddAutomationElement
)
: undefined}
.selectLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
)}
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.tooltipDescription=${this._tab === "targets" &&
!this._selectedGroup}
.tooltipDescription=${this._tab === "targets"}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
([
@@ -981,7 +960,7 @@ class DialogAddAutomationElement
items: this._getBlockItems(this._params!.type, this.hass.localize),
},
]
: !this._filter && this._selectedGroup
: !this._filter && this._tab === "groups" && this._selectedGroup
? [
{
title: this.hass.localize(
@@ -1039,10 +1018,7 @@ class DialogAddAutomationElement
Object.entries(grp).map(([key, options]) =>
options.members
? flattenGroups(options.members)
: options.domains
? // domain elements are appended below from the backend descriptions
[]
: this._convertToItem(key, options, type, localize)
: this._convertToItem(key, options, type, localize)
);
const items = flattenGroups(groups).flat();
@@ -1083,8 +1059,6 @@ class DialogAddAutomationElement
let genericCollectionIndex = -1;
let dynamicCollectionIndex = -1;
const exclusiveDomains = this._getExclusiveDomains(type);
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1115,8 +1089,7 @@ class DialogAddAutomationElement
triggerDescriptions,
manifests,
domains,
types,
exclusiveDomains
types
)
);
@@ -1135,8 +1108,7 @@ class DialogAddAutomationElement
conditionDescriptions,
manifests,
domains,
types,
exclusiveDomains
types
)
);
@@ -1169,19 +1141,9 @@ class DialogAddAutomationElement
}
groups.push(
...collectionGroups
.filter(([, options]) =>
this._groupHasItems(
type,
options,
type === "condition"
? conditionDescriptions
: triggerDescriptions
)
)
.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
...collectionGroups.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
);
if (groups.length) {
@@ -1278,28 +1240,11 @@ class DialogAddAutomationElement
return this._services(localize, services, manifests, group);
}
const groupDef =
TYPES[type].collections[collectionIndex]?.groups[group] ??
TYPES[type].collections.find((collection) => group in collection.groups)
?.groups[group];
const groups = this._getGroups(type, group, collectionIndex);
let result: AddAutomationElementListItem[];
if (groupDef?.domains && !groupDef.members) {
// Curated group whose items come solely from backend domains (e.g. Sun).
result = this._getDomainElementItems(type, groupDef.domains, localize);
} else {
const groups = this._getGroups(type, group, collectionIndex);
result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (groupDef?.domains) {
// Curated group with both static members and backend domains (Time).
result.push(
...this._getDomainElementItems(type, groupDef.domains, localize)
);
}
}
const result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (type === "action") {
if (!this._selectedGroup) {
@@ -1419,8 +1364,7 @@ class DialogAddAutomationElement
triggers: TriggerDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
types: CollectionGroupType[]
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
return [];
@@ -1430,7 +1374,7 @@ class DialogAddAutomationElement
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1492,8 +1436,7 @@ class DialogAddAutomationElement
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
types: CollectionGroupType[]
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
@@ -1503,7 +1446,7 @@ class DialogAddAutomationElement
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1763,93 +1706,22 @@ class DialogAddAutomationElement
options,
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc
): AddAutomationElementListItem => {
// A group either lists explicit members or bundles backend element domains.
const isGroup = !!(options.members || options.domains);
return {
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.description${isGroup ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
};
};
// Domains owned exclusively by a curated group, i.e. a group that bundles
// only domains and no static members (e.g. "sun" under the Sun group). Those
// are hidden from the generic dynamic domain grouping so they don't appear
// both standalone and inside the curated group. Domains of a mixed group
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
// hidden — they still surface as their own domain group as well.
private _getExclusiveDomains = memoizeOne(
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
const domains = new Set<string>();
TYPES[type].collections.forEach((collection) =>
Object.values(collection.groups).forEach((group) => {
if (group.domains && !group.members) {
group.domains.forEach((domain) => domains.add(domain));
}
})
);
return domains;
}
);
private _getDomainElementItems(
type: AddAutomationElementDialogParams["type"],
domains: string[],
localize: LocalizeFunc
): AddAutomationElementListItem[] {
const domainSet = new Set(domains);
if (type === "trigger") {
return Object.keys(this._triggerDescriptions)
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
.map((trigger) =>
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
);
}
if (type === "condition") {
return Object.keys(this._conditionDescriptions)
.filter((condition) => domainSet.has(getConditionDomain(condition)))
.map((condition) =>
this._getConditionListItem(
localize,
getConditionDomain(condition),
condition
)
);
}
return [];
}
private _groupHasItems(
type: AddAutomationElementDialogParams["type"],
options: { members?: object; domains?: string[] },
descriptions: TriggerDescriptions | ConditionDescriptions
): boolean {
if (options.members && Object.keys(options.members).length) {
return true;
}
if (options.domains) {
const domainSet = new Set(options.domains);
const getDomain =
type === "condition" ? getConditionDomain : getTriggerDomain;
return Object.keys(descriptions).some((key) =>
domainSet.has(getDomain(key))
);
}
// plain single-element group
return true;
}
): AddAutomationElementListItem => ({
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
});
private _getDomainGroupedListItems(
localize: LocalizeFunc,
@@ -2093,8 +1965,6 @@ class DialogAddAutomationElement
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedGroup = undefined;
this._selectedCollectionIndex = undefined;
this._selectedTarget = ev.detail.value;
mainWindow.history.pushState(
{
@@ -2116,67 +1986,6 @@ class DialogAddAutomationElement
this._getItemsByTarget();
};
// Time & location groups have no target; picking one drills into its items
// (the same list as the matching group in the "by type" tab).
private _handleTimeLocationGroupSelected = (
ev: ValueChangedEvent<string>
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedTarget = undefined;
this._selectedGroup = ev.detail.value;
this._selectedCollectionIndex = 0;
mainWindow.history.pushState(
{
dialogData: {
group: this._selectedGroup,
collectionIndex: this._selectedCollectionIndex,
},
},
""
);
requestAnimationFrame(() => {
if (this._narrow) {
this._contentElement?.scrollTo(0, 0);
} else {
this._itemsListElement?.scrollTo(0, 0);
}
});
};
private _getTimeLocationLabel(
type: AddAutomationElementDialogParams["type"]
): string | undefined {
if (type !== "trigger" && type !== "condition") {
return undefined;
}
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
}
private _getTimeLocationGroups = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc,
descriptions: TriggerDescriptions | ConditionDescriptions
): AddAutomationElementListItem[] => {
if (type !== "trigger" && type !== "condition") {
return [];
}
return TIME_LOCATION_GROUPS.map(
(group) => [group, TYPES[type].collections[0].groups[group]] as const
)
.filter(
([, options]) =>
options && this._groupHasItems(type, options, descriptions)
)
.map(([group, options]) =>
this._convertToItem(group, options, type, localize)
)
.filter((item) => item.name);
}
);
private _getDefaultStateItems(
type: "trigger" | "condition"
): AddAutomationElementListItem[] {
@@ -63,7 +63,6 @@ import {
} from "../../../../data/target";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
interface Level1Entries {
open: boolean;
@@ -94,16 +93,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public manifests?: DomainManifestLookup;
// Section title + group rows (Time, Location) for the targetless element
// groups. Picking a row drills into that group's items, just like selecting
// the matching group in the "by type" tab.
@property({ attribute: false }) public timeLocationLabel?: string;
@property({ attribute: false })
public timeLocationGroups?: AddAutomationElementListItem[];
@property({ attribute: false }) public selectedGroup?: string;
// #endregion properties
// #region context
@@ -193,20 +182,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
? this._renderNarrow(this._entries, this.value)
: html`
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
this.timeLocationLabel,
this.timeLocationGroups,
this.selectedGroup
)}
${this._renderUnassigned(this.narrow, this._entries, this.value)}
${this._renderLabels(
this.narrow,
this.states,
this._registries,
this._labelRegistry,
this.value
)}
${this._renderLabels(this.narrow, this.value)}
`}
${this.narrow && this._showShowMoreButton && !this._fullHeight
? html`
@@ -366,58 +343,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
);
private _renderTimeLocation = memoizeOne(
(
narrow: boolean,
label?: string,
groups?: AddAutomationElementListItem[],
selectedGroup?: string
) => {
if (!label || !groups?.length) {
return nothing;
}
return html`<ha-section-title>${label}</ha-section-title>
<ha-list-base>
${groups.map(
(group) =>
html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${group.icon
? html`<span slot="start">${group.icon}</span>`
: group.iconPath
? html`<ha-svg-icon
slot="start"
.path=${group.iconPath}
></ha-svg-icon>`
: nothing}
<div slot="headline">${group.name}</div>
${narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
</ha-list-item-button>`
)}
</ha-list-base>`;
}
);
private _renderLabels = memoizeOne(
(
narrow: boolean,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
labelRegistry: LabelRegistryEntry[],
value?: SingleHassServiceTarget
) => {
(narrow: boolean, value?: SingleHassServiceTarget) => {
const labels = this._getLabelsMemoized(
states,
registries.areas,
registries.devices,
registries.entities,
labelRegistry,
this.states,
this._registries.areas,
this._registries.devices,
this._registries.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
@@ -1240,13 +1173,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
}
private _selectTimeLocationGroup(ev: CustomEvent) {
const value = (ev.currentTarget as any).value;
if (value) {
fireEvent(this, "time-location-group-selected", { value });
}
}
private async _valueChanged(itemId: string, expand = false) {
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
@@ -1586,7 +1512,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-from-target": HaAutomationAddFromTarget;
}
interface HASSDomEvents {
"time-location-group-selected": { value: string };
}
}
@@ -1,21 +1,13 @@
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
import { mdiHelpCircleOutline } from "@mdi/js";
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 { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-tooltip";
import type {
ForDict,
PlatformCondition,
} from "../../../../../data/automation";
import type { PlatformCondition } from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
@@ -23,21 +15,11 @@ import {
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { getRecorderEntityOptions } from "../../../../../data/recorder";
import type { TargetSelector } from "../../../../../data/selector";
import {
extractFromTarget,
getTargetEntityCount,
} from "../../../../../data/target";
import { 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 &&
@@ -59,11 +41,6 @@ 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 _recordingCheckId = 0;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
@@ -74,26 +51,6 @@ 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;
}
@@ -306,7 +263,7 @@ export class HaPlatformCondition extends LitElement {
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
) || fieldName}</span
>
${description
? html`<span
@@ -515,118 +472,6 @@ export class HaPlatformCondition extends LitElement {
}
}
// Shows a small info icon beside the `for` duration field's label, with a
// tooltip explaining when history priming can't fully cover the duration.
private _renderForPrimingInfo(fieldName: string) {
if (fieldName !== "for") {
return nothing;
}
const text = this._durationPrimingInfoText();
if (!text) {
return nothing;
}
return html`<ha-svg-icon
id="for-priming-info"
tabindex="0"
class="priming-info-icon"
.path=${mdiAlertOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
}
private _durationPrimingInfoText(): string | undefined {
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 undefined;
}
if (this._targetHasUnrecordedEntity) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
);
}
if (this._durationExceedsLookback(forValue)) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
);
}
return undefined;
}
private _durationExceedsLookback(forValue: unknown): boolean {
const duration = createDurationData(
forValue as string | number | ForDict | undefined
);
if (!duration) {
return false;
}
return (
durationDataToSeconds(duration) >
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 checkId = ++this._recordingCheckId;
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 (checkId !== this._recordingCheckId) {
return;
}
if (!referenced_entities.length) {
this._targetHasUnrecordedEntity = false;
return;
}
const recordingDisabled = await Promise.all(
referenced_entities.map((entityId) =>
getRecorderEntityOptions(this.hass, entityId)
.then((options) => options.recording_disabled_by !== null)
// Unknown entity or command unavailable on older cores: don't warn.
.catch(() => false)
)
);
if (checkId !== this._recordingCheckId) {
return;
}
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
} catch (_err) {
// Target resolution failed; fall back to no warning rather than guessing.
if (checkId === this._recordingCheckId) {
this._targetHasUnrecordedEntity = false;
}
}
}
static styles = css`
:host {
display: block;
@@ -682,15 +527,6 @@ export class HaPlatformCondition extends LitElement {
.clickable {
cursor: pointer;
}
.priming-info-icon {
--mdc-icon-size: 16px;
width: 16px;
height: 16px;
color: var(--warning-color);
margin-inline-start: var(--ha-space-1);
vertical-align: middle;
cursor: help;
}
`;
}
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { load } from "js-yaml";
import { load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
let loaded: any;
try {
loaded = load(paste);
loaded = load(paste, { schema: YAML11_SCHEMA });
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
@@ -1,5 +1,5 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { load } from "js-yaml";
import { load, YAML11_SCHEMA } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, queryAll } from "lit/decorators";
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
let loaded: any;
try {
loaded = load(paste);
loaded = load(paste, { schema: YAML11_SCHEMA });
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
+23 -70
View File
@@ -27,7 +27,6 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -473,22 +472,6 @@ class HUIRoot extends LitElement {
const title_only = !icon_only && !icon_and_title;
const hidden =
!this._editMode && (view.subview || _isTabHiddenForUser(view));
const tabContent = html`
${icon_only || icon_and_title
? html`<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>`
: nothing}
${icon_and_title ? view.title : nothing}
${title_only
? view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
: nothing}
`;
return html`
<ha-tab-group-tab
slot="nav"
@@ -496,9 +479,6 @@ class HUIRoot extends LitElement {
.active=${this._curView === index}
.disabled=${hidden}
aria-label=${ifDefined(view.title)}
data-path=${view.path || index}
@auxclick=${this._handleViewTabNewTabClick}
@click=${this._handleViewTabNewTabClick}
class=${classMap({
"icon-only": Boolean(icon_only),
"icon-and-title": Boolean(icon_and_title),
@@ -515,7 +495,24 @@ class HUIRoot extends LitElement {
@click=${this._moveViewLeft}
.disabled=${this._curView === 0}
></ha-icon-button-arrow-prev>
${tabContent}
`
: nothing}
${icon_only || icon_and_title
? html`<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>`
: nothing}
${icon_and_title ? view.title : nothing}
${title_only
? view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
: nothing}
${this._editMode
? html`
<ha-icon-button
.title=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.edit"
@@ -533,7 +530,7 @@ class HUIRoot extends LitElement {
.disabled=${(this._curView! as number) + 1 === views.length}
></ha-icon-button-arrow-next>
`
: tabContent}
: nothing}
</ha-tab-group-tab>
`;
})}
@@ -574,8 +571,7 @@ class HUIRoot extends LitElement {
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
.href=${this._backPath}
@click=${this._handleBackClick}
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`
@@ -886,27 +882,6 @@ class HUIRoot extends LitElement {
}
}
private _handleBackClick(ev: MouseEvent): void {
if (this._backPath && !isNavigationClick(ev)) {
return;
}
this._goBack();
}
private get _backPath(): string | undefined {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
if (curViewConfig?.back_path != null) {
return curViewConfig.back_path;
}
if (this.backPath) {
return this.backPath;
}
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
private _addDevice = async () => {
await this.hass.loadFragmentTranslation("config");
showAddIntegrationDialog(this, { navigateToResult: true });
@@ -1096,7 +1071,9 @@ class HUIRoot extends LitElement {
}
private _navigateToView(path: string | number, replace?: boolean) {
const url = this._viewUrl(path);
const url = this.lovelace!.editMode
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
: `${this.route!.prefix}/${path}${location.search}`;
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
@@ -1104,30 +1081,6 @@ class HUIRoot extends LitElement {
}
}
private _viewUrl(path: string | number): string {
return this.lovelace!.editMode
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
: `${this.route!.prefix}/${path}${location.search}`;
}
private _handleViewTabNewTabClick(ev: MouseEvent): void {
if (
this._editMode ||
(ev.button !== 1 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey)
) {
return;
}
ev.preventDefault();
ev.stopPropagation();
const tab = ev.currentTarget as HTMLElement;
const path = tab.dataset.path;
if (path) {
window.open(this._viewUrl(path), "_blank", "noreferrer");
}
}
private _editView() {
showEditViewDialog(this, {
lovelace: this.lovelace!,
+4 -15
View File
@@ -5107,7 +5107,6 @@
"select_target": "Select a target",
"home": "Home",
"unassigned": "Unassigned",
"time_sun": "Time and sun",
"blocks": "Blocks",
"tabs": {
"target": "By target",
@@ -5183,11 +5182,8 @@
"entity": {
"label": "Entity"
},
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
"time_location": {
"label": "Time and location"
},
"generic": {
"label": "Generic"
@@ -5440,10 +5436,6 @@
"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%]",
@@ -5468,11 +5460,8 @@
"entity": {
"label": "Entity"
},
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
"time_location": {
"label": "Time and location"
},
"generic": {
"label": "Generic"
@@ -1,8 +1,6 @@
import { assert, describe, it } from "vitest";
import durationToSeconds, {
durationDataToSeconds,
} from "../../../src/common/datetime/duration_to_seconds";
import durationToSeconds from "../../../src/common/datetime/duration_to_seconds";
describe("durationToSeconds", () => {
it("works", () => {
@@ -10,23 +8,3 @@ describe("durationToSeconds", () => {
assert.strictEqual(durationToSeconds("11:01:05"), 39665);
});
});
describe("durationDataToSeconds", () => {
it("sums all duration fields", () => {
assert.strictEqual(
durationDataToSeconds({
days: 1,
hours: 2,
minutes: 3,
seconds: 4,
milliseconds: 500,
}),
93784.5
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToSeconds({}), 0);
assert.strictEqual(durationDataToSeconds({ hours: 6 }), 21600);
});
});
+1 -3
View File
@@ -66,9 +66,7 @@ export class HaTest extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+17 -4
View File
@@ -54,16 +54,29 @@ async function assertPageLoads(page: Page, hash: string, selector: string) {
}
// Errors that are gallery-harness artifacts rather than bugs in the component
// under test. The Lit-context init-error family that used to live here is gone:
// ha-gallery now provides fallback contexts for every demo (mirroring the real
// app's root), so context-consuming components resolve `localize`, formatters,
// config, etc. synchronously instead of throwing during init.
// 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'\)/,
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env node
// Builds and posts a PR comment summarising Playwright E2E failures from the
// merged JSON report. Invoked from the `report` job in .github/workflows/e2e.yaml
// via actions/github-script:
//
// const { default: postReportComment } =
// await import("${{ github.workspace }}/test/e2e/post-report-comment.mjs");
// await postReportComment({ github, context, core });
import { readFileSync } from "fs";
const REPORT_PATH = "test/e2e/reports/combined/results.json";
// GitHub comment bodies cap at 65536 chars; leave headroom.
const MAX_BODY = 60000;
// Strip ANSI colour codes that Playwright bakes into error messages.
// eslint-disable-next-line no-control-regex
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
// Walk the JSON report tree and collect every failing spec with its error
// output, so the comment shows the actual test failures.
const collectFailures = (report) => {
const failures = [];
const walk = (suite, titlePath) => {
const here = suite.title ? [...titlePath, suite.title] : titlePath;
for (const spec of suite.specs ?? []) {
if (spec.ok) continue;
const errors = [];
for (const test of spec.tests ?? []) {
for (const result of test.results ?? []) {
for (const err of result.errors ?? []) {
if (err.message) errors.push(stripAnsi(err.message));
}
}
}
failures.push({
title: [...here, spec.title].join(" "),
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
errors,
});
}
for (const child of suite.suites ?? []) walk(child, here);
};
for (const suite of report.suites ?? []) walk(suite, []);
return failures;
};
const formatFailure = (failure) => {
const output =
failure.errors.join("\n\n").trim() || "(no error output captured)";
return [
`<details><summary>❌ ${failure.title} <code>${failure.location}</code></summary>`,
"",
"```ts",
output,
"```",
"",
"</details>",
].join("\n");
};
export default async function postReportComment({ github, context, core }) {
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
let failures = [];
try {
const report = JSON.parse(readFileSync(REPORT_PATH, "utf8"));
stats = report.stats ?? stats;
failures = collectFailures(report);
} catch (err) {
core.warning(`Could not parse Playwright JSON report: ${err.message}`);
}
const summaryLine =
`**${stats.unexpected} failed**, ${stats.expected} passed` +
(stats.flaky ? `, ${stats.flaky} flaky` : "") +
(stats.skipped ? `, ${stats.skipped} skipped` : "");
const details = failures.length
? failures.map(formatFailure).join("\n")
: "_No failing tests were captured in the report._";
let body = [
"## Playwright E2E tests failed",
"",
summaryLine,
"",
details,
"",
"The combined HTML report is available as a workflow artifact.",
"",
`[View workflow run](${runUrl})`,
].join("\n");
if (body.length > MAX_BODY) {
body = `${body.slice(0, MAX_BODY)}\n\n_…report truncated, see the full HTML report artifact._`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: context.issue.number,
body,
});
}
+28 -25
View File
@@ -4194,14 +4194,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:1.61.0":
version: 1.61.0
resolution: "@playwright/test@npm:1.61.0"
"@playwright/test@npm:1.60.0":
version: 1.60.0
resolution: "@playwright/test@npm:1.60.0"
dependencies:
playwright: "npm:1.61.0"
playwright: "npm:1.60.0"
bin:
playwright: cli.js
checksum: 10/359a9a4a59a87361d416818966bb810a38970d9f2b8364349d22099fb23eb043530714374a83010f9f3471c7e758df43c49bf32128745af13277adfb211aa752
checksum: 10/a13d369014e1934b0aa484c5d59537f5249af0fe006ac4ecbcbe14c673221412706193ea2d9cf3b2c0cc69e3ddbb4daddb006f0bedfdeb05f687776ed8c35f5f
languageName: node
linkType: hard
@@ -5550,13 +5550,6 @@ __metadata:
languageName: node
linkType: hard
"@types/js-yaml@npm:4.0.9":
version: 4.0.9
resolution: "@types/js-yaml@npm:4.0.9"
checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6
languageName: node
linkType: hard
"@types/jsesc@npm:^2.5.0":
version: 2.5.1
resolution: "@types/jsesc@npm:2.5.1"
@@ -9748,7 +9741,7 @@ __metadata:
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.61.0"
"@playwright/test": "npm:1.60.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.5.15"
"@rspack/core": "npm:2.0.8"
@@ -9763,7 +9756,6 @@ __metadata:
"@types/color-name": "npm:2.0.0"
"@types/culori": "npm:4.0.1"
"@types/html-minifier-terser": "npm:7.0.2"
"@types/js-yaml": "npm:4.0.9"
"@types/leaflet": "npm:1.9.21"
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
@@ -9819,7 +9811,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.5"
intl-messageformat: "npm:11.2.8"
js-yaml: "npm:4.2.0"
js-yaml: "npm:5.0.0"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10762,7 +10754,18 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:4.2.0, js-yaml@npm:^4.1.0":
"js-yaml@npm:5.0.0":
version: 5.0.0
resolution: "js-yaml@npm:5.0.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/51691da5a1b3894ffdd6ffe386d061fecf8769d135560dce8f3d3b7c5f7b167ec7fbe2c232c91135d58216b2f6ba215e85ac60fe569c46f0a9024f2ff6dec47e
languageName: node
linkType: hard
"js-yaml@npm:^4.1.0":
version: 4.2.0
resolution: "js-yaml@npm:4.2.0"
dependencies:
@@ -12600,27 +12603,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.61.0":
version: 1.61.0
resolution: "playwright-core@npm:1.61.0"
"playwright-core@npm:1.60.0":
version: 1.60.0
resolution: "playwright-core@npm:1.60.0"
bin:
playwright-core: cli.js
checksum: 10/e7ac4b2ef1c5f1701ec8086e47bc341988a3f753009d450e2a62daab5ade1fa943f1ef7fb48c721618dd7bd42667a11ff73c2e5dbd0674c20a3397b3c5be2f61
checksum: 10/66c0f83d627e673261c848dd6fe1f2856d5b5b6859268acb61a45c00f5bf926e596a351a9c481a3a4e82a45022c7c6b5d99ebc3906fc6876ac582ed6f7e16190
languageName: node
linkType: hard
"playwright@npm:1.61.0":
version: 1.61.0
resolution: "playwright@npm:1.61.0"
"playwright@npm:1.60.0":
version: 1.60.0
resolution: "playwright@npm:1.60.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.61.0"
playwright-core: "npm:1.60.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10/b5faf97391315334a30e88e03216fd6090988b99896e71b341995a27c49d49dcebc825ec389dabc9b2d2cab6368c418cad9cbb925a88de35fb3b26a19bf05bea
checksum: 10/8569770637ee35d08cca3b53a5b56c21e9236bd1ac4718456d5988fb8acd51c5b3cc2cf90748363a36199529e870a9b6c68d6fc9e19571261cd867005d6331c1
languageName: node
linkType: hard