Compare commits

..
Author SHA1 Message Date
Jan-Philipp BeneckeandClaude Opus 5 f56e4d155c Process code review comments
Give the leaf nodes `role="img"`. Dropping `role="button"` left them with
the implicit generic role, on which ARIA prohibits an author-provided
name, so the labels risked being ignored and the nodes going back to
being unnamed focus stops. A node draws one step and slots only
decoration, so `img` names it without claiming it is a control. The
branches keep `role="group"`, since `img` would make their focusable
children presentational. `aria-disabled` goes with the button role: it is
unsupported on `img`, and the state is already part of the name.

Assert the nested steps in the label coverage test. It only checked the
building blocks and the choose options, so a regression in the recursion
through branch children would have gone unnoticed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 08:35:18 +02:00
Jan-Philipp BeneckeandClaude Opus 5 2e28098b73 Process code review comments
Fix the memoization of the trace labels: `hass` is replaced on every
state update, so comparing it rebuilt every label on every state event,
which was worse than the rerendering the labels were hoisted out of the
graph to avoid. Compare everything but `hass`, and let an absent entity
registry stay undefined rather than allocating a new array each render.

Report whether a condition passed or failed instead of only that it ran.
That outcome is otherwise shown by the tracked path and by a cross on a
node that is aria-hidden, so it never reached a screen reader.

Drop `role="button"` from the nodes. Selection follows focus and nothing
handles clicks or key presses, so the role promised an activation that
does not exist. The branches keep `role="group"`, which makes no such
promise.

Pass the integration manifests through from both trace panels, so the
names match the ones the automation editor shows.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 08:21:22 +02:00
Jan-Philipp BeneckeandClaude Opus 5 a5a5ac6a1c Describe trace graph nodes for screen readers
The trace graph is drawn entirely in SVG. Its nodes and branches are
keyboard reachable, but carried no accessible name and no role, so a
screen reader announced nothing while tabbing through a run. Everything
the graph says about the outcome of a step — the error badge, the
strike-through, the dashed outline, the repeat count — was visual only.

Each focusable stop now carries the same step description the automation
editor shows, followed by what the run did with that step. The ARIA is
written at the call sites in hat-script-graph, which is the only place
that knows whether an element is a step or just layout: the branch heads,
the then/else arrows, the failed-condition cross and the spacers are
marked aria-hidden, while the branch that merely groups the triggers is
left alone so the triggers inside it stay in the accessibility tree.

The names themselves are built by buildTraceLabels, a pure function that
walks the trace tree and returns one string per node path, reusing the
same describers the editor uses. The trace panels call it because they
already own hass, and memoize it on the trace, so the graph takes a plain
record and stays a leaf: it subscribes to nothing, and entity state
changes no longer rerender it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 07:47:55 +02:00
41 changed files with 661 additions and 1010 deletions
+1 -2
View File
@@ -118,8 +118,7 @@ For user-facing changes, establish the existing design context as part of fronte
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
+4 -4
View File
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
## When To Add Tests
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
- Do not try to cover every scenario, especially for behaviour that changes often.
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
-5
View File
@@ -1,5 +0,0 @@
export const GITHUB_CORE_ISSUES_URL =
"https://github.com/home-assistant/core/issues";
export const GITHUB_FRONTEND_ISSUES_URL =
"https://github.com/home-assistant/frontend/issues";
+3 -16
View File
@@ -123,8 +123,6 @@ export class HaDataTable extends LitElement {
@property({ type: Array }) public data: DataTableRowData[] = [];
@property({ type: Boolean }) public loading = false;
@property({ type: Boolean }) public selectable = false;
@property({ type: Boolean }) public clickable = false;
@@ -167,9 +165,6 @@ export class HaDataTable extends LitElement {
@state() private _filteredData?: DataTableRowData[];
// Row count of the data that _filteredData was computed from
@state() private _filteredDataSourceLength = 0;
@state() private _headerHeight = 0;
@query("slot[name='header']") private _header!: HTMLSlotElement;
@@ -528,9 +523,7 @@ export class HaDataTable extends LitElement {
role="cell"
>
${
this.loading ||
!this._filteredData ||
(this.data.length && !this._filteredDataSourceLength)
!this._filteredData
? this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
@@ -728,11 +721,10 @@ export class HaDataTable extends LitElement {
!this._lastUpdate ||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
const sourceData = this.data;
let filteredData = sourceData;
let filteredData = this.data;
if (this._filter) {
filteredData = await this._memFilterData(
sourceData,
this.data,
this._sortColumns,
this._filter.trim()
);
@@ -768,13 +760,8 @@ export class HaDataTable extends LitElement {
return;
}
if (startTime < this._lastUpdate) {
return;
}
this._lastUpdate = startTime;
this._filteredData = data;
this._filteredDataSourceLength = sourceData.length;
}
private _groupData = memoizeOne(
+2 -2
View File
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
return;
}
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
(response) => response.json()
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
response.json()
);
chunks[chunk] = iconPromise;
this._setPath(iconPromise, iconName, requestedIcon);
+1 -8
View File
@@ -6,7 +6,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import {
configContext,
connectionContext,
narrowViewportContext,
uiContext,
@@ -33,10 +32,6 @@ class HaMenuButton extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _ui?: ContextType<typeof uiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state() private _hasNotifications = false;
@state() private _show = false;
@@ -87,8 +82,7 @@ class HaMenuButton extends LitElement {
if (
!changedProps.has("_narrow") &&
!changedProps.has("_ui") &&
!changedProps.has("_connection") &&
!changedProps.has("_config")
!changedProps.has("_connection")
) {
return;
}
@@ -100,7 +94,6 @@ class HaMenuButton extends LitElement {
const showButton =
this._ui?.kioskMode === false &&
this._config?.auth.external?.config.hasSidebar !== true &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
this._show = showButton || this._alwaysVisible;
@@ -1,34 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { HaSkeleton } from "./ha-skeleton";
/**
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
* (24px by default), including inherited size overrides.
*/
@customElement("ha-skeleton-icon")
export class HaSkeletonIcon extends HaSkeleton {
static get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
vertical-align: middle;
flex: none;
width: var(--mdc-icon-size, 24px);
height: var(--mdc-icon-size, 24px);
min-height: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-icon": HaSkeletonIcon;
}
}
@@ -1,36 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { HaSkeleton } from "./ha-skeleton";
/**
* Placeholder for a line of text, matching the tile secondary text skeleton.
* Its height follows the surrounding font size. Set `width` in CSS to fit the
* expected text.
*
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
*/
@customElement("ha-skeleton-text")
export class HaSkeletonText extends HaSkeleton {
static get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
vertical-align: middle;
width: var(--ha-skeleton-text-width, 140px);
max-width: 100%;
height: 1em;
min-height: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-text": HaSkeletonText;
}
}
-56
View File
@@ -1,56 +0,0 @@
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
/**
* Placeholder shown while content loads. Sized by its container unless a
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
*
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
*/
@customElement("ha-skeleton")
export class HaSkeleton extends WaSkeleton {
override effect: WaSkeleton["effect"] = "pulse";
static get styles(): CSSResultGroup {
return [
WaSkeleton.styles,
css`
:host {
--color: var(
--ha-skeleton-color,
var(--ha-color-fill-neutral-normal-resting)
);
--sheen-color: var(
--ha-skeleton-sheen-color,
var(--ha-color-fill-neutral-loud-resting)
);
--wa-border-radius-pill: var(
--ha-skeleton-border-radius,
var(--ha-border-radius-sm)
);
}
@media (forced-colors: active) {
:host {
--color: GrayText;
}
}
@media (prefers-reduced-motion: reduce) {
:host([effect="pulse"]) .indicator,
:host([effect="sheen"]) .indicator {
animation: none;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton": HaSkeleton;
}
}
+10 -2
View File
@@ -1,4 +1,4 @@
import "../skeleton/ha-skeleton-text";
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
${
this.secondaryLoading
? html`<div class="secondary">
<ha-skeleton-text></ha-skeleton-text>
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
</div>`
: html`<slot name="secondary" class="secondary">
<span>${this.secondary}</span>
@@ -150,6 +150,14 @@ export class HaTileInfo extends LitElement {
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
}
.placeholder {
width: 140px;
max-width: 100%;
height: var(--tile-info-secondary-font-size);
--wa-border-radius-pill: var(--ha-border-radius-sm);
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}
+57 -1
View File
@@ -16,6 +16,7 @@ import {
} from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import type { PropertyValues } from "lit";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
@@ -74,6 +75,9 @@ export class HatScriptGraph extends LitElement {
@property({ attribute: false }) public selected?: string;
/** Accessible name per node path, from `buildTraceLabels`. */
@property({ attribute: false }) public labels?: Record<string, string>;
@query("hat-graph-node[active], hat-graph-branch[active]")
private _activeNode?: HTMLElement;
@@ -106,6 +110,9 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.notEnabled=${node.disabled}
.error=${node.error}
role="img"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
tabindex=${hasTrace ? "0" : "-1"}
>
<ha-trigger-icon
@@ -153,6 +160,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${node.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(node.disabled || undefined)}
>
<hat-graph-node
.graphStart=${graphStart}
@@ -164,6 +175,7 @@ export class HatScriptGraph extends LitElement {
.error=${node.error}
slot="head"
nofocus
aria-hidden="true"
></hat-graph-node>
${node.branches.slice(0, -1).map(
@@ -187,6 +199,9 @@ export class HatScriptGraph extends LitElement {
?track=${branch.hasTrace}
?active=${this.selected === branch.path}
.notEnabled=${branch.disabled}
role="img"
aria-label=${ifDefined(this.labels?.[branch.path])}
aria-current=${ifDefined(this.selected === branch.path || undefined)}
></hat-graph-node>
${branch.children.map((action) => this._renderActionNode(action))}
</div>
@@ -196,7 +211,10 @@ export class HatScriptGraph extends LitElement {
?track=${defaultBranch.hasTrace}
?unfinished=${defaultBranch.unfinished}
>
<hat-graph-spacer ?track=${defaultBranch.hasTrace}></hat-graph-spacer>
<hat-graph-spacer
aria-hidden="true"
?track=${defaultBranch.hasTrace}
></hat-graph-spacer>
${defaultBranch.children.map((action) =>
this._renderActionNode(action)
)}
@@ -215,6 +233,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${node.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(node.disabled || undefined)}
>
<hat-graph-node
.graphStart=${graphStart}
@@ -226,6 +248,7 @@ export class HatScriptGraph extends LitElement {
.error=${node.error}
slot="head"
nofocus
aria-hidden="true"
></hat-graph-node>
${
config.else
@@ -240,12 +263,14 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.notEnabled=${elseBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node
>${elseBranch.children.map((action) =>
this._renderActionNode(action)
)}
</div>`
: html`<hat-graph-spacer
aria-hidden="true"
?track=${elseBranch.hasTrace}
></hat-graph-spacer>`
}
@@ -260,6 +285,7 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.notEnabled=${thenBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node>
${thenBranch.children.map((action) => this._renderActionNode(action))}
</div>
@@ -281,6 +307,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${model.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(model.disabled || undefined)}
tabindex=${hasTrace ? "0" : "-1"}
short
>
@@ -293,6 +323,7 @@ export class HatScriptGraph extends LitElement {
.error=${model.error}
?building-block=${CONDITION_BUILDING_BLOCKS.includes(condition)}
nofocus
aria-hidden="true"
>
<ha-condition-icon
slot="icon"
@@ -308,6 +339,7 @@ export class HatScriptGraph extends LitElement {
<hat-graph-node
.iconPath=${mdiClose}
nofocus
aria-hidden="true"
?track=${failed}
?active=${this.selected === path}
.notEnabled=${model.disabled}
@@ -329,6 +361,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${model.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(model.disabled || undefined)}
>
<hat-graph-node
.graphStart=${graphStart}
@@ -341,6 +377,7 @@ export class HatScriptGraph extends LitElement {
.badge=${model.badge}
slot="head"
nofocus
aria-hidden="true"
></hat-graph-node>
<div
class="repeat-sequence"
@@ -372,6 +409,9 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.notEnabled=${model.disabled}
.error=${model.error}
role="img"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
tabindex=${model.hasTrace ? "0" : "-1"}
>
${
@@ -405,6 +445,9 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.notEnabled=${model.disabled}
.error=${model.error}
role="img"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
tabindex=${model.hasTrace ? "0" : "-1"}
></hat-graph-node>
`;
@@ -423,6 +466,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${model.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(model.disabled || undefined)}
>
<div
class="graph-container"
@@ -439,6 +486,7 @@ export class HatScriptGraph extends LitElement {
.error=${model.error}
slot="head"
nofocus
aria-hidden="true"
></hat-graph-node>
${branch.children.map((action) => this._renderActionNode(action))}
</div>
@@ -458,6 +506,10 @@ export class HatScriptGraph extends LitElement {
?track=${track}
?active=${this.selected === path}
.notEnabled=${model.disabled}
role="group"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
aria-disabled=${ifDefined(model.disabled || undefined)}
>
<hat-graph-node
.graphStart=${graphStart}
@@ -469,6 +521,7 @@ export class HatScriptGraph extends LitElement {
.error=${model.error}
slot="head"
nofocus
aria-hidden="true"
></hat-graph-node>
${model.branches.map(
(branch) =>
@@ -496,6 +549,9 @@ export class HatScriptGraph extends LitElement {
?active=${this.selected === path}
.error=${model.error}
.notEnabled=${model.disabled}
role="img"
aria-label=${ifDefined(this.labels?.[path])}
aria-current=${ifDefined(this.selected === path || undefined)}
></hat-graph-node>
`;
}
+2 -14
View File
@@ -1,11 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { GITHUB_CORE_ISSUES_URL } from "../common/url/github";
import {
createQueryString,
type QueryParamConfig,
} from "../common/url/query-params";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -113,19 +108,12 @@ const sanitizeManifest = <T extends IntegrationManifest | undefined>(
} as T)
: manifest;
const integrationIssuesQueryParams = {
string: ["q"],
} as const satisfies QueryParamConfig;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
sanitizeHttpUrl(manifest.issue_tracker) ||
`${GITHUB_CORE_ISSUES_URL}?${createQueryString(
{ q: `is:issue is:open label:"integration: ${domain}"` },
integrationIssuesQueryParams
)}`;
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
localize: LocalizeFunc,
@@ -167,7 +155,7 @@ export const fetchIntegrationManifestsCollection = async (
};
export const fetchIntegrationManifest = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
integration: string
) =>
hass
+1 -1
View File
@@ -81,7 +81,7 @@ type SystemHealthEvent =
| SystemHealthEventFinish;
export const subscribeSystemHealthInfo = (
hass: Pick<HomeAssistant, "connection">,
hass: HomeAssistant,
callback: (info: SystemHealthInfo | undefined) => void
) => {
let data = {};
+1 -5
View File
@@ -5,7 +5,7 @@ export type SystemLogLevel =
export interface LoggedError {
name: string;
message: [string, ...string[]];
message: [string];
level: SystemLogLevel;
source: [string, number];
exception: string;
@@ -41,10 +41,6 @@ export const getLoggedErrorIntegration = (item: LoggedError) => {
return item.source[0].split("/")[2];
}
if (item.source[0].startsWith("components/")) {
return item.source[0].split("/")[1];
}
return undefined;
};
-90
View File
@@ -1,90 +0,0 @@
import {
GITHUB_CORE_ISSUES_URL,
GITHUB_FRONTEND_ISSUES_URL,
} from "../common/url/github";
import {
createQueryString,
type QueryParamConfig,
} from "../common/url/query-params";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { documentationUrlForVersion } from "../util/documentation-url";
import type { IntegrationManifest } from "./integration";
import type { LoggedError } from "./system_log";
import {
getLoggedErrorIntegration,
isCustomIntegrationError,
} from "./system_log";
const frontendIssueQueryParams = {
string: ["template", "core_version", "javascript_errors"],
} as const satisfies QueryParamConfig;
const coreIssueQueryParams = {
string: [
"template",
"version",
"installation_type",
"integration_name",
"integration_link",
"logs",
],
} as const satisfies QueryParamConfig;
const coreIssueTemplateUrl = `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
{ template: "bug_report.yml" },
coreIssueQueryParams
)}`;
export const systemLogReportUrl = (
item: LoggedError,
coreVersion: string,
manifest?: IntegrationManifest | null,
installationType?: string
): string => {
const log = [
item.name,
item.source.join(":"),
...item.message,
item.exception,
]
.filter(Boolean)
.join("\n\n");
const includeLogs = new URLSearchParams({ log }).toString().length <= 6000;
if (/^frontend\.js(?:_dev)?(?:\.|$)/.test(item.name)) {
return `${GITHUB_FRONTEND_ISSUES_URL}/new?${createQueryString(
{
template: "bug_report.yml",
core_version: coreVersion,
javascript_errors: includeLogs ? log : undefined,
},
frontendIssueQueryParams
)}`;
}
if (getLoggedErrorIntegration(item) && !manifest) {
return coreIssueTemplateUrl;
}
if (isCustomIntegrationError(item) || manifest?.is_built_in === false) {
return sanitizeHttpUrl(manifest?.issue_tracker) || coreIssueTemplateUrl;
}
return `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
{
template: "bug_report.yml",
version: coreVersion,
installation_type: installationType,
integration_name: manifest?.name,
integration_link: manifest
? documentationUrlForVersion(
coreVersion,
`/integrations/${encodeURIComponent(manifest.domain)}/`
)
: undefined,
logs: includeLogs ? log : undefined,
},
coreIssueQueryParams
)}`;
};
+173
View File
@@ -0,0 +1,173 @@
import { ensureArray } from "../common/array/ensure-array";
import type { HomeAssistant } from "../types";
import type { Condition, Trigger } from "./automation";
import { describeCondition, describeTrigger } from "./automation_i18n";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { DomainManifestLookup } from "./integration";
import { describeAction } from "./script_i18n";
import type { TraceExtended } from "./trace";
import { TraceTree } from "./trace-tree";
import type { TraceActionNode, TraceBranch, TraceNode } from "./trace-tree";
const STATE_KEY = "ui.panel.config.automation.trace.graph.state";
/** Shared so an absent registry does not break the callers' memoization. */
const NO_ENTITY_REGISTRY: EntityRegistryEntry[] = [];
interface LabelContext {
hass: HomeAssistant;
entityRegistry: EntityRegistryEntry[];
manifests?: DomainManifestLookup;
}
type StatefulNode = Pick<TraceNode, "disabled" | "error" | "track"> &
Partial<Pick<TraceNode, "notTriggered" | "condition">>;
/**
* Whether a condition passed is drawn as the tracked path, and failing as a
* cross on a node that is aria-hidden, so the outcome needs saying. A repeated
* condition can have done both across its evaluations.
*/
const conditionOutcome = (
condition: NonNullable<TraceNode["condition"]>
): "passed" | "failed" | "passed_and_failed" | undefined => {
if (condition.passed && condition.failed) {
return "passed_and_failed";
}
if (condition.passed) {
return "passed";
}
if (condition.failed) {
return "failed";
}
return undefined;
};
/** The graph shows the run outcome only visually, so the label says it. */
const withState = (
{ hass }: LabelContext,
description: string,
node: StatefulNode,
badge?: number
): string => {
const outcome = node.condition && conditionOutcome(node.condition);
let state: string;
if (node.disabled) {
state = hass.localize(`${STATE_KEY}.disabled`);
} else if (node.error) {
state = hass.localize(`${STATE_KEY}.error`);
} else if (node.notTriggered) {
state = hass.localize(`${STATE_KEY}.not_triggered`);
} else if (outcome) {
state = hass.localize(`${STATE_KEY}.${outcome}`);
} else if (node.track && badge) {
state = hass.localize(`${STATE_KEY}.repeated`, { count: badge });
} else if (node.track) {
state = hass.localize(`${STATE_KEY}.executed`);
} else {
state = hass.localize(`${STATE_KEY}.not_executed`);
}
return hass.localize("ui.panel.config.automation.trace.graph.node_label", {
description,
state,
});
};
/** Named by its first condition, as `ha-automation-option-row` does. */
const optionLabel = (ctx: LabelContext, branch: TraceBranch): string => {
const conditions = branch.option?.conditions
? ensureArray<Condition | string>(branch.option.conditions)
: undefined;
let description: string;
if (!conditions || conditions.length === 0) {
description = ctx.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.no_conditions"
);
} else if (typeof conditions[0] === "string") {
description = conditions[0];
} else {
description = describeCondition(
conditions[0],
ctx.hass,
ctx.entityRegistry
);
}
if (conditions && conditions.length > 1) {
description += ctx.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option_description_additional",
{ numberOfAdditionalConditions: conditions.length - 1 }
);
}
return withState(ctx, description, {
disabled: branch.disabled,
error: false,
track: branch.hasTrace,
});
};
const addAction = (
ctx: LabelContext,
labels: Record<string, string>,
node: TraceActionNode
): void => {
labels[node.path] = withState(
ctx,
// The tree's `actionType` is a wider union than describeAction accepts;
// it derives the type from the config anyway.
describeAction(
ctx.hass,
ctx.entityRegistry,
node.config,
undefined,
undefined,
ctx.manifests
),
node,
node.badge
);
for (const branch of node.branches) {
// Only `choose` branches carry an option, which gets a node of its own.
if (branch.option) {
labels[branch.path] = optionLabel(ctx, branch);
}
for (const child of branch.children) {
addAction(ctx, labels, child);
}
}
};
/**
* Accessible names for every step of a trace, keyed by node path. Called from
* the trace panels, which own `hass`; memoize on everything but `hass`, which
* is replaced on every state update.
*/
export const buildTraceLabels = (
trace: TraceExtended,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[] | undefined,
manifests?: DomainManifestLookup
): Record<string, string> => {
const entities = entityRegistry ?? NO_ENTITY_REGISTRY;
const ctx: LabelContext = { hass, entityRegistry: entities, manifests };
const tree = new TraceTree(trace);
const labels: Record<string, string> = {};
tree.triggers?.forEach((node: TraceNode<Trigger>) => {
labels[node.path] = withState(
ctx,
describeTrigger(node.config, hass, entities),
node
);
});
tree.conditions.forEach((node: TraceNode<Condition>) => {
labels[node.path] = withState(
ctx,
describeCondition(node.config, hass, entities),
node
);
});
tree.actions.forEach((node) => addAction(ctx, labels, node));
tree.sequence.forEach((node) => addAction(ctx, labels, node));
return labels;
};
@@ -1,5 +1,4 @@
import "../../../../components/skeleton/ha-skeleton";
import "../../../../components/skeleton/ha-skeleton-text";
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -113,14 +112,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><ha-skeleton-text></ha-skeleton-text
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
><ha-skeleton></ha-skeleton
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
@@ -308,7 +307,6 @@ export class HaMoreInfoUpdateBackup extends LitElement {
width: 48px;
height: 24px;
display: block;
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
}
`;
}
+1 -8
View File
@@ -154,12 +154,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
*/
@property({ type: Boolean }) public empty = false;
/**
* Show a loading state instead of the empty message until data is ready.
* @type {Boolean}
*/
@property({ type: Boolean }) public loading = false;
@property({ attribute: false }) public route!: Route;
/**
@@ -498,7 +492,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: nothing
}
${
this.empty && !this.loading
this.empty
? html`<div class="center">
<slot name="empty">${this.noDataText}</slot>
</div>`
@@ -520,7 +514,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}
.loading=${this.loading}
.noDataText=${this.noDataText}
.filter=${this.filter}
.selectable=${this._selectMode}
+14 -8
View File
@@ -1,29 +1,35 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import type { TemplateResult } from "lit";
import { css, html } from "lit";
import "../components/skeleton/ha-skeleton-text";
/** Placeholder shown in place of a text while onboarding translations load. */
export const renderSkeleton = (variant: string): TemplateResult =>
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
export const skeletonStyles = css`
.skeleton {
height: 1em;
vertical-align: middle;
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
.skeleton.title {
--ha-skeleton-text-width: 200px;
width: 200px;
}
.skeleton.line {
--ha-skeleton-text-width: 100%;
width: 100%;
}
.skeleton.headline {
--ha-skeleton-text-width: 40%;
width: 40%;
margin-bottom: var(--ha-space-1);
}
.skeleton.chip {
--ha-skeleton-text-width: 80px;
width: 80px;
}
.skeleton.button {
--ha-skeleton-text-width: 120px;
width: 120px;
}
.skeleton.label {
--ha-skeleton-text-width: 80px;
width: 80px;
}
`;
@@ -123,8 +123,6 @@ const RATING_ICON = {
8: mdiNumeric8,
};
const MAX_RATING = 8;
const POLL_INTERVAL_SECONDS = 5;
@customElement("supervisor-app-info")
@@ -1092,8 +1090,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`ui.panel.config.apps.dashboard.capability.${id}.title` as LocalizeKeys
),
text: this.i18n.localize(
`ui.panel.config.apps.dashboard.capability.${id}.description`,
{ max: MAX_RATING }
`ui.panel.config.apps.dashboard.capability.${id}.description`
),
});
}
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0 0 var(--ha-space-6);
--dialog-content-padding: 0;
}
`,
];
@@ -12,6 +12,7 @@ import {
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import {
fireEvent,
@@ -38,7 +39,13 @@ import type {
NodeInfo,
} from "../../../components/trace/hat-script-graph";
import type { AutomationEntity } from "../../../data/automation";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import {
fireRelatedContext,
fullEntitiesContext,
manifestsContext,
} from "../../../data/context";
import type { DomainManifestLookup } from "../../../data/integration";
import { buildTraceLabels } from "../../../data/trace-labels";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { LogbookEntry } from "../../../data/logbook";
import { getLogbookDataForContext } from "../../../data/logbook";
@@ -76,6 +83,9 @@ export class HaAutomationTrace extends LitElement {
@property({ attribute: false }) public route!: Route;
@state()
@consume({ context: manifestsContext, subscribe: true })
_manifests?: DomainManifestLookup;
@consume({ context: fullEntitiesContext, subscribe: true })
_entityRegistry?: EntityRegistryEntry[];
@@ -97,6 +107,17 @@ export class HaAutomationTrace extends LitElement {
@query("hat-script-graph") private _graph?: HatScriptGraph;
/**
* `hass` is replaced on every state update, so comparing it would rebuild
* every label on every state event. The run already happened, so only the
* trace and the registries the descriptions read can change the result.
*/
private _traceLabels = memoizeOne(
buildTraceLabels,
([trace, , entities, manifests], [pTrace, , pEntities, pManifests]) =>
trace === pTrace && entities === pEntities && manifests === pManifests
);
protected render(): TemplateResult {
const stateObj = this._entityId
? this.hass.states[this._entityId]
@@ -277,6 +298,16 @@ export class HaAutomationTrace extends LitElement {
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
.labels=${
this._trace
? this._traceLabels(
this._trace,
this.hass,
this._entityRegistry,
this._manifests
)
: undefined
}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0 0 var(--ha-space-6);
--dialog-content-padding: 0;
}
`,
];
@@ -168,6 +168,10 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
overflow: hidden;
}
.card-header {
padding-bottom: 0;
}
.card-content {
color: var(--secondary-text-color);
padding-bottom: var(--ha-space-2);
+5 -14
View File
@@ -35,12 +35,9 @@ class HaConfigDevices extends HassRouterPage {
@state() private _manifests: IntegrationManifest[] = [];
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadData();
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadData();
}
protected updatePageEl(pageEl) {
@@ -58,14 +55,8 @@ class HaConfigDevices extends HassRouterPage {
}
private async _loadData() {
await Promise.all([
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries;
}),
fetchIntegrationManifests(this.hass).then((manifests) => {
this._manifests = manifests;
}),
]);
this._configEntries = await getConfigEntries(this.hass);
this._manifests = await fetchIntegrationManifests(this.hass);
}
}
@@ -18,9 +18,6 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-svg-icon";
import { getSignedPath } from "../../../../../data/auth";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr";
+1 -8
View File
@@ -108,8 +108,6 @@ export class HaConfigLabels extends LitElement {
@state() private _labels: LabelRegistryEntry[] = [];
@state() private _loading = true;
@state()
@storage({
storage: "sessionStorage",
@@ -260,7 +258,6 @@ export class HaConfigLabels extends LitElement {
.tabs=${configSections.areas}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._data(this._labels)}
.loading=${this._loading}
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
has-fab
.initialSorting=${this._activeSorting}
@@ -324,11 +321,7 @@ export class HaConfigLabels extends LitElement {
}
private async _fetchLabels() {
try {
this._labels = await fetchLabelRegistry(this.hass.connection);
} finally {
this._loading = false;
}
this._labels = await fetchLabelRegistry(this.hass.connection);
}
private _addLabel() {
@@ -1,27 +1,13 @@
import "../../../components/skeleton/ha-skeleton-text";
import { consume, type ContextType } from "@lit/context";
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import {
GITHUB_CORE_ISSUES_URL,
GITHUB_FRONTEND_ISSUES_URL,
} from "../../../common/url/github";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import "../../../components/ha-alert";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog";
import {
apiContext,
configContext,
connectionContext,
internationalizationContext,
} from "../../../data/context";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
@@ -32,13 +18,9 @@ import {
getLoggedErrorIntegration,
isCustomIntegrationError,
} from "../../../data/system_log";
import { systemLogReportUrl } from "../../../data/system_log_report";
import { subscribeSystemHealthInfo } from "../../../data/system_health";
import { haStyleDialog } from "../../../resources/styles";
import {
DOCUMENTATION_URL,
documentationUrl,
} from "../../../util/documentation-url";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
@@ -54,36 +36,16 @@ const isOfficialDocumentationUrl = (url: string): boolean => {
@customElement("dialog-system-log-detail")
class DialogSystemLogDetail extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: SystemLogDetailDialogParams;
@state() private _manifest?: IntegrationManifest | null;
@state() private _installationType?: string;
@state() private _manifest?: IntegrationManifest;
@state() private _open = false;
@query(".contents") private _contents?: HTMLElement;
private _reportUrl = memoizeOne(systemLogReportUrl);
private _fetchingInstallationType = false;
public async showDialog(params: SystemLogDetailDialogParams): Promise<void> {
this._params = params;
this._manifest = undefined;
@@ -102,27 +64,12 @@ class DialogSystemLogDetail extends LitElement {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(!changedProps.has("_params") && !changedProps.has("_manifest")) ||
!this._params
) {
if (!changedProps.has("_params") || !this._params) {
return;
}
const integration = getLoggedErrorIntegration(this._params.item);
if (changedProps.has("_params") && integration) {
this._fetchManifest(integration, this._params);
}
if (
!/^frontend\.js(?:_dev)?(?:\.|$)/.test(this._params.item.name) &&
!isCustomIntegrationError(this._params.item) &&
(!integration || this._manifest?.is_built_in) &&
isComponentLoaded(this._config.config, "system_health")
) {
this._fetchInstallationType();
if (integration) {
this._fetchManifest(integration);
}
}
@@ -134,24 +81,6 @@ class DialogSystemLogDetail extends LitElement {
const integration = getLoggedErrorIntegration(item);
const reportUrl = this._reportUrl(
item,
this._connection.connection.haVersion,
this._manifest,
this._installationType
);
const reportTarget = reportUrl.startsWith(`${GITHUB_CORE_ISSUES_URL}/`)
? "core"
: reportUrl.startsWith(`${GITHUB_FRONTEND_ISSUES_URL}/`)
? "frontend"
: "custom";
const reportMessage =
this.isCustomIntegration && reportTarget === "core"
? "custom_fallback"
: reportTarget;
const showDocumentation =
this._manifest &&
(this._manifest.is_built_in ||
@@ -160,12 +89,12 @@ class DialogSystemLogDetail extends LitElement {
!isOfficialDocumentationUrl(this._manifest.documentation)));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this._config, `/integrations/${this._manifest.domain}`)
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
const title = this._i18n.localize("ui.panel.config.logs.details", {
const title = this.hass.localize("ui.panel.config.logs.details", {
level: html`<span class=${item.level}
>${this._i18n.localize(`ui.panel.config.logs.level.${item.level}`)}</span
>${this.hass.localize(`ui.panel.config.logs.level.${item.level}`)}</span
>`,
});
@@ -178,82 +107,34 @@ class DialogSystemLogDetail extends LitElement {
<span slot="headerTitle">${title}</span>
<ha-icon-button
id="copy"
autofocus
@click=${this._copyLog}
slot="headerActionItems"
.label=${this._i18n.localize("ui.panel.config.logs.copy")}
.label=${this.hass.localize("ui.panel.config.logs.copy")}
.path=${mdiContentCopy}
></ha-icon-button>
${
integration &&
this._manifest === undefined &&
reportTarget !== "frontend"
? html`<ha-alert
alert-type=${this.isCustomIntegration ? "warning" : "info"}
>
<ha-skeleton-text></ha-skeleton-text>
</ha-alert>`
: html`<ha-alert
alert-type=${this.isCustomIntegration ? "warning" : "info"}
>
<p>
${this._i18n.localize(
`ui.panel.config.logs.detail.report_issue.${reportMessage}.introduction`,
{
integration:
this._manifest?.name ??
(integration
? domainToName(this._i18n.localize, integration)
: new URL(reportUrl).hostname),
}
)}
</p>
<p>
${this._i18n.localize(
`ui.panel.config.logs.detail.report_issue.${reportMessage}.report`,
{
report_link: html`<a
href=${reportUrl}
target="_blank"
rel="noopener noreferrer"
>${this._i18n.localize(`ui.panel.config.logs.detail.report_issue.${reportTarget}.link_text`)}</a
>`,
}
)}
</p>
${
reportMessage !== "custom"
? html`<p>
${this._i18n.localize(
`ui.panel.config.logs.detail.report_issue.${reportMessage}.guidance`,
{
guide_link: html`<a
href=${`${DOCUMENTATION_URL}/help/reporting_issues/`}
target="_blank"
rel="noopener noreferrer"
>${this._i18n.localize("ui.panel.config.logs.detail.report_issue.guide_link_text")}</a
>`,
}
)}
</p>`
: nothing
}
this.isCustomIntegration
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.logs.error_from_custom_integration"
)}
</ha-alert>`
: ""
}
<div class="contents">
<div class="contents" tabindex="-1" autofocus>
<p>
${this._i18n.localize("ui.panel.config.logs.detail.logger")}:
${this.hass.localize("ui.panel.config.logs.detail.logger")}:
${item.name}<br />
${this._i18n.localize("ui.panel.config.logs.detail.source")}:
${this.hass.localize("ui.panel.config.logs.detail.source")}:
${item.source.join(":")}
${
integration
? html`
<br />
${this._i18n.localize(
${this.hass.localize(
"ui.panel.config.logs.detail.integration"
)}:
${domainToName(this._i18n.localize, integration)}
${domainToName(this.hass.localize, integration)}
${
!this._manifest ||
// Can happen with custom integrations
@@ -265,7 +146,7 @@ class DialogSystemLogDetail extends LitElement {
href=${documentationLink}
target="_blank"
rel="noreferrer"
>${this._i18n.localize(
>${this.hass.localize(
"ui.panel.config.logs.detail.documentation"
)}</a
>${
@@ -279,7 +160,7 @@ class DialogSystemLogDetail extends LitElement {
)}
target="_blank"
rel="noreferrer"
>${this._i18n.localize(
>${this.hass.localize(
"ui.panel.config.logs.detail.issues"
)}</a
>`
@@ -294,15 +175,15 @@ class DialogSystemLogDetail extends LitElement {
${
item.count > 0
? html`
${this._i18n.localize(
${this.hass.localize(
"ui.panel.config.logs.detail.first_occurred"
)}:
${formatSystemLogTime(
item.first_occurred,
this._i18n.locale,
this._config.config
this.hass!.locale,
this.hass!.config
)}
(${this._i18n.localize(
(${this.hass.localize(
"ui.panel.config.logs.detail.number_of_occurrences",
{
count: item.count,
@@ -311,11 +192,11 @@ class DialogSystemLogDetail extends LitElement {
`
: ""
}
${this._i18n.localize("ui.panel.config.logs.detail.last_logged")}:
${this.hass.localize("ui.panel.config.logs.detail.last_logged")}:
${formatSystemLogTime(
item.timestamp,
this._i18n.locale,
this._config.config
this.hass!.locale,
this.hass!.config
)}
</p>
${
@@ -339,43 +220,12 @@ class DialogSystemLogDetail extends LitElement {
: isCustomIntegrationError(this._params!.item);
}
private async _fetchManifest(
integration: string,
params: SystemLogDetailDialogParams
) {
let manifest: IntegrationManifest | null;
private async _fetchManifest(integration: string) {
try {
manifest = await fetchIntegrationManifest(this._api, integration);
} catch {
// Ignore if loading manifest fails. Probably bad JSON in manifest.
manifest = null;
this._manifest = await fetchIntegrationManifest(this.hass, integration);
} catch (_err: any) {
// Ignore if loading manifest fails. Probably bad JSON in manifest
}
if (this._params === params && this._open) {
this._manifest = manifest;
}
}
private _fetchInstallationType() {
if (this._installationType || this._fetchingInstallationType) {
return;
}
this._fetchingInstallationType = true;
const subscription = subscribeSystemHealthInfo(this._connection, (info) => {
this._fetchingInstallationType = false;
if (!info) {
return;
}
this._installationType = info.homeassistant?.info.installation_type;
subscription.then((unsub) => unsub?.());
}).catch(() => {
// The report remains usable without system health information.
this._fetchingInstallationType = false;
});
}
private async _copyLog(): Promise<void> {
@@ -385,7 +235,7 @@ class DialogSystemLogDetail extends LitElement {
if (this.isCustomIntegration) {
text =
this._i18n.localize(
this.hass.localize(
"ui.panel.config.logs.error_from_custom_integration"
) +
"\n\n" +
@@ -394,7 +244,7 @@ class DialogSystemLogDetail extends LitElement {
await copyToClipboard(text);
showToast(this, {
message: this._i18n.localize("ui.common.copied_clipboard"),
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
@@ -402,6 +252,10 @@ class DialogSystemLogDetail extends LitElement {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0px;
}
a {
color: var(--primary-color);
}
@@ -414,35 +268,10 @@ class DialogSystemLogDetail extends LitElement {
}
ha-alert {
display: block;
margin-inline: calc(-1 * var(--ha-space-3));
margin-block-end: var(--ha-space-4);
}
ha-alert p {
margin: 0;
}
ha-alert p + p {
margin-block-start: var(--ha-space-2);
}
ha-skeleton-text {
--ha-skeleton-text-width: 320px;
}
@supports (color: color-mix(in srgb, black, transparent)) {
ha-alert[alert-type="info"] ha-skeleton-text {
--ha-skeleton-color: color-mix(
in srgb,
var(--info-color) 24%,
transparent
);
}
ha-alert[alert-type="warning"] ha-skeleton-text {
--ha-skeleton-color: color-mix(
in srgb,
var(--warning-color) 24%,
transparent
);
}
margin: -4px 0;
}
.contents {
padding: 16px;
outline: none;
direction: ltr;
}
+32 -1
View File
@@ -12,6 +12,7 @@ import {
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import {
fireEvent,
@@ -36,7 +37,13 @@ import type {
HatScriptGraph,
NodeInfo,
} from "../../../components/trace/hat-script-graph";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import {
fireRelatedContext,
fullEntitiesContext,
manifestsContext,
} from "../../../data/context";
import type { DomainManifestLookup } from "../../../data/integration";
import { buildTraceLabels } from "../../../data/trace-labels";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { LogbookEntry } from "../../../data/logbook";
import { getLogbookDataForContext } from "../../../data/logbook";
@@ -72,6 +79,9 @@ export class HaScriptTrace extends LitElement {
@property({ attribute: false }) public route!: Route;
@state()
@consume({ context: manifestsContext, subscribe: true })
_manifests?: DomainManifestLookup;
@consume({ context: fullEntitiesContext, subscribe: true })
_entityRegistry?: EntityRegistryEntry[];
@@ -93,6 +103,17 @@ export class HaScriptTrace extends LitElement {
@query("hat-script-graph") private _graph?: HatScriptGraph;
/**
* `hass` is replaced on every state update, so comparing it would rebuild
* every label on every state event. The run already happened, so only the
* trace and the registries the descriptions read can change the result.
*/
private _traceLabels = memoizeOne(
buildTraceLabels,
([trace, , entities, manifests], [pTrace, , pEntities, pManifests]) =>
trace === pTrace && entities === pEntities && manifests === pManifests
);
protected render(): TemplateResult {
const stateObj = this._entityId
? this.hass.states[this._entityId]
@@ -257,6 +278,16 @@ export class HaScriptTrace extends LitElement {
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
.labels=${
this._trace
? this._traceLabels(
this._trace,
this.hass,
this._entityRegistry,
this._manifests
)
: undefined
}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
@@ -10,7 +10,7 @@ import { clearStatistics, getStatisticLabel } from "../../../../data/recorder";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import type { DialogStatisticsFixParams } from "./show-dialog-statistics-fix";
@customElement("dialog-statistics-fix")
@@ -13,14 +13,7 @@ import {
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { HassEntity } from "home-assistant-js-websocket";
import { consume, type ContextType } from "@lit/context";
import {
css,
type CSSResultGroup,
html,
LitElement,
nothing,
type PropertyValues,
} from "lit";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
@@ -74,7 +67,7 @@ import { getAreaTableColumn } from "../../common/data-table-columns";
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistantRegistries } from "../../../../types";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import { fixStatisticsIssue } from "./fix-statistics";
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
@@ -115,8 +108,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
@state() private _loading = true;
@state() private filter = "";
@state() private _selected: string[] = [];
@@ -155,12 +146,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
@query("ha-input-search") private _searchInput!: HaInputSearch;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._validateStatistics();
}
protected firstUpdated() {
this._validateStatistics();
}
private _displayData = memoizeOne(
@@ -567,7 +554,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
}
<ha-data-table
.narrow=${this.narrow}
.loading=${this._loading}
.columns=${columns}
.data=${this._displayData(
this._data,
@@ -736,42 +722,38 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
}
private async _validateStatistics() {
try {
const [statisticIds, issues] = await Promise.all([
getStatisticIds(this._api),
validateStatistics(this._api),
]);
const [statisticIds, issues] = await Promise.all([
getStatisticIds(this._api),
validateStatistics(this._api),
]);
updateStatisticsIssues(this._api);
updateStatisticsIssues(this._api);
const statsIds = new Set();
const statsIds = new Set();
this._data = statisticIds.map((statistic) => {
statsIds.add(statistic.statistic_id);
return {
...statistic,
state: this._states[statistic.statistic_id],
issues: issues[statistic.statistic_id],
};
});
this._data = statisticIds.map((statistic) => {
statsIds.add(statistic.statistic_id);
return {
...statistic,
state: this._states[statistic.statistic_id],
issues: issues[statistic.statistic_id],
};
});
Object.keys(issues).forEach((statisticId) => {
if (!statsIds.has(statisticId)) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
source: "",
state: this._states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_sum: false,
unit_class: null,
});
}
});
} finally {
this._loading = false;
}
Object.keys(issues).forEach((statisticId) => {
if (!statsIds.has(statisticId)) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
source: "",
state: this._states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_sum: false,
unit_class: null,
});
}
});
}
private _clearSelected = async () => {
@@ -340,9 +340,6 @@ class HaRefreshTokens extends LitElement {
ha-list-item-base {
--ha-row-item-padding-inline: 0;
}
[slot="supporting-text"] {
white-space: normal;
}
ha-icon-button {
color: var(--primary-text-color);
}
+1 -5
View File
@@ -1,7 +1,3 @@
export const loadVirtualizer = async () => {
// The default flow layout is otherwise only fetched once there are items.
await Promise.all([
import("@lit-labs/virtualizer"),
import("@lit-labs/virtualizer/layouts/flow.js"),
]);
await import("@lit-labs/virtualizer");
};
+16 -27
View File
@@ -3143,7 +3143,7 @@
},
"rating": {
"title": "Security rating",
"description": "This shows the security rating of the app on a scale from 1 to {max}. Higher is better."
"description": "This shows the security rating of the app. Higher is better."
},
"host_network": {
"title": "Host network",
@@ -4719,7 +4719,7 @@
"debug": "DEBUG"
},
"custom_integration": "custom integration",
"error_from_custom_integration": "This log entry is from a custom integration.",
"error_from_custom_integration": "This error originated from a custom integration.",
"show_full_logs": "Show raw logs",
"show_condensed_logs": "Show condensed logs",
"select_number_of_lines": "Select number of lines to download",
@@ -4745,31 +4745,6 @@
"startups_ago": "{boot} startups ago",
"detail": {
"logger": "Logger",
"report_issue": {
"frontend": {
"introduction": "This log entry was reported by a browser (or the Home Assistant app).",
"report": "If you think it relates to Home Assistant’s user interface, {report_link}.",
"guidance": "Core, custom integrations, or custom Frontend modules may also be involved. See the {guide_link} for further guidance.",
"link_text": "report it on the Frontend repository"
},
"core": {
"introduction": "This log entry was reported by Home Assistant Core.",
"report": "If you think it indicates a bug, {report_link}.",
"guidance": "See the {guide_link} for further guidance.",
"link_text": "report it on the Core repository"
},
"custom": {
"introduction": "This log entry is from {integration}, a custom integration.",
"report": "If you think it indicates a bug, you can {report_link}.",
"link_text": "report it on its issue tracker"
},
"custom_fallback": {
"introduction": "This log entry is from {integration}, a custom integration. Its issue tracker is unavailable.",
"report": "If you think it indicates a Home Assistant Core bug, {report_link}.",
"guidance": "See the {guide_link} for further guidance."
},
"guide_link_text": "reporting guide"
},
"source": "Source",
"integration": "[%key:ui::components::related-items::integration%]",
"documentation": "documentation",
@@ -6215,6 +6190,20 @@
"no_traces_found": "No traces found",
"trace_no_longer_available": "Chosen trace is no longer available",
"enter_downloaded_trace": "Enter downloaded trace",
"graph": {
"node_label": "{description}, {state}",
"state": {
"executed": "executed",
"not_executed": "not executed",
"passed": "passed",
"failed": "failed",
"passed_and_failed": "passed and failed",
"not_triggered": "did not trigger",
"error": "error",
"disabled": "disabled",
"repeated": "repeated {count} times"
}
},
"tabs": {
"details": "Step details",
"timeline": "Trace timeline",
+7 -12
View File
@@ -1,15 +1,10 @@
import type { HomeAssistantConfig } from "../types";
const DOCUMENTATION_DOMAIN = "home-assistant.io";
export const DOCUMENTATION_URL = `https://www.${DOCUMENTATION_DOMAIN}`;
export const documentationUrl = (
{ config }: Pick<HomeAssistantConfig, "config">,
path: string
) => documentationUrlForVersion(config.version, path);
export const documentationUrlForVersion = (version: string, path: string) =>
export const documentationUrl = (hass: HomeAssistantConfig, path: string) =>
`https://${
version.includes("b") ? "rc" : version.includes("dev") ? "next" : "www"
}.${DOCUMENTATION_DOMAIN}${path}`;
hass.config.version.includes("b")
? "rc"
: hass.config.version.includes("dev")
? "next"
: "www"
}.home-assistant.io${path}`;
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import type { HomeAssistant } from "../../src/types";
import type { AutomationTraceExtended } from "../../src/data/trace";
import { buildTraceLabels } from "../../src/data/trace-labels";
// These labels are the only thing a screen reader gets from the graph, which
// is pure SVG. The run outcome in particular is otherwise conveyed only by
// colour, dashes and badges.
const timestamp = "2026-09-17T00:00:00Z";
/** `localize` echoes key and placeholders, so assertions skip English copy. */
const hassStub = () =>
({
localize: (key: string, params?: Record<string, unknown>) =>
params ? `${key}(${Object.values(params).join("|")})` : key,
locale: { language: "en" },
states: {},
services: {},
entities: {},
config: {},
formatEntityState: () => "",
formatEntityAttributeValue: () => "",
}) as unknown as HomeAssistant;
const createTrace = (
config: Record<string, unknown>,
trace: AutomationTraceExtended["trace"] = {}
): AutomationTraceExtended =>
({
domain: "automation",
item_id: "test",
run_id: "test",
state: "stopped",
script_execution: "finished",
last_step: null,
timestamp: { start: timestamp, finish: timestamp },
context: { id: "test", user_id: null },
trigger: "manual",
config: {
alias: "Test",
triggers: [],
conditions: [],
actions: [],
...config,
},
trace,
}) as AutomationTraceExtended;
const build = (trace: AutomationTraceExtended) =>
buildTraceLabels(trace, hassStub(), undefined);
const step = (path: string, extra: Record<string, unknown> = {}) => ({
[path]: [{ path, timestamp, ...extra }],
});
describe("buildTraceLabels", () => {
it("says a step ran", () => {
const labels = build(
createTrace({ actions: [{ delay: 1 }] }, step("action/0") as never)
);
expect(labels["action/0"]).toContain("state.executed");
});
it("says a step the run never reached did not run", () => {
const labels = build(createTrace({ actions: [{ delay: 1 }] }));
expect(labels["action/0"]).toContain("state.not_executed");
});
it("prefers disabled over not executed", () => {
// A disabled step is never tracked either, so the order of the checks is
// what decides which of the two a user hears.
const labels = build(
createTrace({ actions: [{ delay: 1, enabled: false }] })
);
expect(labels["action/0"]).toContain("state.disabled");
expect(labels["action/0"]).not.toContain("not_executed");
});
it("prefers an error over the tracked state", () => {
const labels = build(
createTrace(
{ actions: [{ delay: 1 }] },
step("action/0", { error: "boom" }) as never
)
);
expect(labels["action/0"]).toContain("state.error");
});
it("counts the iterations of a repeat", () => {
const labels = build(
createTrace(
{ actions: [{ repeat: { count: 2, sequence: [{ delay: 1 }] } }] },
{
// Core records the repeat step itself as well as each iteration.
"action/0": [{ path: "action/0", timestamp }],
"action/0/repeat/sequence/0": [
{ path: "action/0/repeat/sequence/0", timestamp },
{ path: "action/0/repeat/sequence/0", timestamp },
],
} as never
)
);
expect(labels["action/0"]).toContain("state.repeated");
});
describe("conditions", () => {
// A condition is tracked whether it passed or failed, so reading `track`
// alone would report both as "executed" and hide the outcome that matters.
const conditionTrace = (result: boolean) =>
createTrace(
{ conditions: [{ condition: "state", entity_id: "light.kitchen" }] },
{
"condition/0": [
{ path: "condition/0", timestamp, result: { result } },
],
} as never
);
it("says a condition passed", () => {
expect(build(conditionTrace(true))["condition/0"]).toContain(
"state.passed"
);
});
it("says a condition failed rather than merely executed", () => {
const label = build(conditionTrace(false))["condition/0"];
expect(label).toContain("state.failed");
expect(label).not.toContain("state.executed");
});
it("reports both outcomes when a repeated condition did each", () => {
const labels = build(
createTrace(
{ conditions: [{ condition: "state", entity_id: "light.kitchen" }] },
{
"condition/0": [
{ path: "condition/0", timestamp, result: { result: true } },
{ path: "condition/0", timestamp, result: { result: false } },
],
} as never
)
);
expect(labels["condition/0"]).toContain("state.passed_and_failed");
});
it("says an unevaluated condition did not run", () => {
const labels = build(
createTrace({
conditions: [{ condition: "state", entity_id: "light.kitchen" }],
})
);
expect(labels["condition/0"]).toContain("state.not_executed");
});
});
it("names every node of a nested config, down to the nested steps", () => {
const labels = build(
createTrace({
triggers: [{ trigger: "state", entity_id: "light.kitchen" }],
conditions: [{ condition: "state", entity_id: "light.kitchen" }],
actions: [
{
choose: [
{ conditions: [], sequence: [{ delay: 1 }] },
{ conditions: [], sequence: [{ delay: 2 }] },
],
},
{ if: [], then: [{ delay: 1 }], else: [{ delay: 2 }] },
{ parallel: [{ sequence: [{ delay: 1 }] }] },
{ repeat: { count: 2, sequence: [{ delay: 1 }] } },
],
})
);
for (const path of [
"trigger/0",
"condition/0",
// The building blocks themselves...
"action/0",
"action/0/choose/0",
"action/0/choose/1",
"action/1",
"action/2",
"action/3",
// ...and the steps nested inside each branch, which only the recursion
// in `addAction` reaches.
"action/0/choose/0/sequence/0",
"action/0/choose/1/sequence/0",
"action/1/then/0",
"action/1/else/0",
"action/2/parallel/0/sequence/0",
"action/3/repeat/sequence/0",
]) {
expect(labels[path], path).toBeTruthy();
}
});
it("works without an entity registry, which loads lazily", () => {
const labels = build(
createTrace({
triggers: [{ trigger: "state", entity_id: "light.kitchen" }],
})
);
expect(labels["trigger/0"]).toBeTruthy();
});
});
-218
View File
@@ -5,8 +5,6 @@
* yarn test:e2e:app
*/
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { load } from "js-yaml";
import {
appSidebar,
appSidebarConfig,
@@ -17,7 +15,6 @@ import {
ensureAppSidebarPanelVisible,
goToPanel,
openMoreInfoDialog,
openSystemLogDetail,
} from "./app/src/helpers";
import {
expectNoPageErrors,
@@ -166,221 +163,6 @@ test.describe("Quick search", () => {
defineRouteSmokeTests(appRouteSmokeGroups);
test.describe("System log reporting", () => {
let errors: ReturnType<typeof trackPageErrors>;
test.beforeEach(async ({ page }) => {
errors = trackPageErrors(page);
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text: string) => {
document.body.dataset.copiedReport = text;
},
},
});
});
});
test.afterEach(() => {
expectNoPageErrors(errors);
});
test("opens the frontend form directly with the selected log details", async ({
page,
context,
}) => {
const requests: string[] = [];
await context.route("https://github.com/**", async (route) => {
requests.push(route.request().url());
await route.fulfill({
contentType: "text/html",
body: "Issue form fixture",
});
});
const dialog = await openSystemLogDetail(
page,
"TypeError: Report fixture failed"
);
const reportLink = dialog.getByRole("link", {
name: "report it on the Frontend repository",
exact: true,
});
expect(requests).toEqual([]);
await expect(
dialog.getByRole("link", { name: "reporting guide", exact: true })
).toHaveAttribute(
"href",
"https://www.home-assistant.io/help/reporting_issues/"
);
const popup = context.waitForEvent("page");
await reportLink.click();
await (await popup).waitForLoadState();
expect(requests).toHaveLength(1);
const submitted = new URL(requests[0]);
expect(submitted.pathname).toBe("/home-assistant/frontend/issues/new");
expect(Object.fromEntries(submitted.searchParams)).toEqual({
template: "bug_report.yml",
core_version: expect.any(String),
javascript_errors:
"frontend.js.modern.202609180\n\ncomponents/system_log/__init__.py:350\n\nUncaught error from Firefox 140.0 on Linux\nTypeError: Report fixture failed\nrender@src/example.ts:10:2\n\nAnother occurrence: café & ? # %\n```",
});
const form = load(
readFileSync(".github/ISSUE_TEMPLATE/bug_report.yml", "utf8")
);
for (const id of ["core_version", "javascript_errors"]) {
expect(form).toEqual(
expect.objectContaining({
body: expect.arrayContaining([
expect.objectContaining({
id,
type: expect.stringMatching(/^(input|textarea)$/),
}),
]),
})
);
}
await expect(dialog.locator(".contents")).toBeVisible();
});
for (const { message, destination, params } of [
...[
"Built-in integration error",
"Error with relative integration source",
].map((logMessage) => ({
message: logMessage,
destination: "https://github.com/home-assistant/core/issues/new",
params: {
template: "bug_report.yml",
version: expect.any(String),
installation_type: "Home Assistant OS",
logs: expect.stringContaining("ValueError:"),
integration_name: "Philips Hue",
integration_link: "https://www.home-assistant.io/integrations/hue/",
},
})),
{
message: "General Core error",
destination: "https://github.com/home-assistant/core/issues/new",
params: {
template: "bug_report.yml",
version: expect.any(String),
installation_type: "Home Assistant OS",
logs: expect.stringContaining("RuntimeError: Test error"),
},
},
...["Custom integration error", "Custom override error"].map(
(logMessage) => ({
message: logMessage,
destination: "https://example.com/issues",
params: { project: "example" },
})
),
]) {
test(`routes ${message} to its issue tracker`, async ({ page }) => {
const dialog = await openSystemLogDetail(page, message);
const link = dialog.getByRole("link", {
name: /^report /i,
exact: true,
});
await expect(link).toHaveAttribute("target", "_blank");
await expect(async () => {
const url = new URL((await link.getAttribute("href")) ?? "");
expect(`${url.origin}${url.pathname}`).toBe(destination);
expect(Object.fromEntries(url.searchParams)).toEqual(params);
if ("logs" in params) {
expect(url.searchParams.get("logs")).toContain(message);
}
}).toPass({ timeout: QUICK_TIMEOUT });
});
}
for (const message of [
"Missing tracker error",
"Unsafe tracker error",
"Failed manifest error",
]) {
test(`falls back to the base template for ${message}`, async ({ page }) => {
const dialog = await openSystemLogDetail(page, message);
await expect(
dialog.getByRole("link", {
name: /^report /i,
exact: true,
})
).toHaveAttribute(
"href",
"https://github.com/home-assistant/core/issues/new?template=bug_report.yml"
);
await dialog.locator("#copy").click();
await expect(page.locator("body")).toHaveAttribute(
"data-copied-report",
new RegExp(message)
);
});
}
test("ignores a manifest response after selecting another entry", async ({
page,
}) => {
await goToPanel(page, "/?scenario=system-log-reporting#/config/logs");
await page
.locator("system-log-card ha-list-item")
.filter({ hasText: "Delayed manifest error" })
.click();
const detail = page.locator("dialog-system-log-detail");
await expect(
detail.getByRole("link", {
name: /^report /i,
exact: true,
})
).not.toBeAttached();
await detail.getByRole("button", { name: "Close", exact: true }).click();
await expect(detail.locator("ha-dialog")).not.toBeAttached();
await page
.locator("system-log-card ha-list-item")
.filter({ hasText: "Built-in integration error" })
.click();
await page.evaluate(() => window.resolveReportManifest?.());
await expect(
detail.getByRole("link", {
name: /^report /i,
exact: true,
})
).toHaveAttribute(
"href",
/\/home-assistant\/core\/issues\/new\?.*integration_name=Philips\+Hue/
);
});
test("waits for the manifest before offering a report destination", async ({
page,
}) => {
const dialog = await openSystemLogDetail(page, "Delayed manifest error");
const link = dialog.getByRole("link", { name: /^report /i, exact: true });
await expect(link).not.toBeAttached();
await page.evaluate(() => window.resolveReportManifest?.());
await expect(link).toHaveAttribute(
"href",
"https://example.com/delayed/issues"
);
});
});
test("keeps the launch screen until initial panel content renders", async ({
page,
}) => {
-13
View File
@@ -27,19 +27,6 @@ export async function goToPanel(page: Page, path: string) {
]);
}
export async function openSystemLogDetail(page: Page, message: string) {
await goToPanel(page, "/?scenario=system-log-reporting#/config/logs");
await page
.locator("system-log-card ha-list-item")
.filter({ hasText: message })
.click();
const dialog = page.locator("dialog-system-log-detail");
await expect(dialog.locator(".contents")).toContainText(message);
return dialog;
}
// The hass-more-info event is one-shot: if it lands before the shell's
// listener is attached it is silently dropped. Re-dispatching is idempotent
// (showDialog just resets the dialog to the requested view), so poll the
+1 -2
View File
@@ -264,8 +264,7 @@ export async function openOnboarding(page: Page, baseURL: string) {
export async function createOwner(page: Page) {
await page
.locator("onboarding-welcome")
.getByRole("button", { name: "Create my smart home", exact: true })
.locator("onboarding-welcome ha-button.start")
.click({ timeout: SHELL_TIMEOUT });
const inputs = page.locator("onboarding-create-user ha-input >> input");
-131
View File
@@ -17,15 +17,6 @@ import {
type ForecastEvent,
} from "../../../../../src/data/weather";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import type { LoggedError } from "../../../../../src/data/system_log";
import type { IntegrationManifest } from "../../../../../src/data/integration";
import { manifest } from "../../../../../demo/src/stubs/manifest";
declare global {
interface Window {
resolveReportManifest?: () => void;
}
}
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -371,127 +362,6 @@ const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
hass.mockWS("media_source/browse_media", () => browsePromise);
};
const systemLogReportingScenario: Scenario = async (hass) => {
await hass.loadFragmentTranslation("config");
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "system_health"],
},
});
const entries: Pick<
LoggedError,
"name" | "message" | "source" | "exception"
>[] = [
{
name: "frontend.js.modern.202609180",
message: [
"Uncaught error from Firefox 140.0 on Linux\nTypeError: Report fixture failed\nrender@src/example.ts:10:2",
"Another occurrence: café & ? # %\n```",
],
source: ["components/system_log/__init__.py", 350],
exception: "",
},
{
name: "homeassistant.components.hue",
message: ["Built-in integration error"],
source: ["components/hue/light.py", 20],
exception:
"Traceback (most recent call last):\nValueError: Invalid light",
},
{
name: "homeassistant.core",
message: ["General Core error"],
source: ["core.py", 50],
exception: "RuntimeError: Test error",
},
{
name: "third_party_library",
message: ["Error with relative integration source"],
source: ["components/hue/light.py", 21],
exception: "ValueError: Invalid response",
},
{
name: "custom_components.example",
message: ["Custom integration error"],
source: ["custom_components/example/sensor.py", 30],
exception: "ValueError: Custom fixture",
},
...[
{ domain: "no_tracker", message: "Missing tracker error" },
{ domain: "unsafe_tracker", message: "Unsafe tracker error" },
{ domain: "delayed_manifest", message: "Delayed manifest error" },
].map(
({
domain,
message,
}): Pick<LoggedError, "name" | "message" | "source" | "exception"> => ({
name: `custom_components.${domain}`,
message: [message],
source: [`custom_components/${domain}/sensor.py`, 30],
exception: "",
})
),
{
name: "homeassistant.components.overridden",
message: ["Custom override error"],
source: ["components/overridden/sensor.py", 30],
exception: "",
},
{
name: "homeassistant.components.failed_manifest",
message: ["Failed manifest error"],
source: ["components/failed_manifest/sensor.py", 30],
exception: "",
},
];
hass.mockWS("system_log/list", () =>
entries.map((entry): LoggedError => ({
...entry,
level: "error",
count: 1,
timestamp: 1789760000,
first_occurred: 1789760000,
}))
);
hass.mockWS("manifest/get", ({ integration }: { integration: string }) => {
switch (integration) {
case "failed_manifest":
return Promise.reject(new Error("Manifest unavailable"));
case "delayed_manifest":
return new Promise<IntegrationManifest>((resolve) => {
window.resolveReportManifest = () =>
resolve(
manifest(integration, "Delayed integration", {
is_built_in: false,
issue_tracker: "https://example.com/delayed/issues",
})
);
});
case "example":
case "overridden":
return manifest(integration, "Custom example", {
is_built_in: false,
issue_tracker: "https://example.com/issues?project=example",
});
case "no_tracker":
return manifest(integration, "Missing tracker", { is_built_in: false });
case "unsafe_tracker":
return manifest(integration, "Unsafe tracker", {
is_built_in: false,
issue_tracker: "data:text/html,unsafe",
});
default:
return manifest(
integration,
integration === "hue" ? "Philips Hue" : integration
);
}
});
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -511,5 +381,4 @@ export const scenarios: Record<string, Scenario> = {
"weather-more-info": weatherMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
"delayed-lovelace": delayedLovelaceScenario,
"system-log-reporting": systemLogReportingScenario,
};
+3 -3
View File
@@ -11443,13 +11443,13 @@ __metadata:
linkType: hard
"js-yaml@npm:^4.1.0":
version: 4.3.2
resolution: "js-yaml@npm:4.3.2"
version: 4.3.1
resolution: "js-yaml@npm:4.3.1"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7
checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42
languageName: node
linkType: hard