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
25 changed files with 596 additions and 1149 deletions
-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";
+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;
};
@@ -11,7 +11,6 @@ import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import { handleNativeBackButtonPressed } from "./external_back_button";
import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
@@ -100,16 +99,6 @@ export const handleExternalMessage = (
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
} else if (msg.command === "back_button/pressed") {
if (!handleNativeBackButtonPressed()) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
return true;
}
} else {
return false;
}
-137
View File
@@ -1,137 +0,0 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { HomeAssistant } from "../types";
import type { ExternalMessaging } from "./external_messaging";
/*
Apps that draw their own toolbar tell us so with `hasNativeBackButton`. We then
hide our own back arrow and instead report whether the current top bar offers a
back action, so the app can show or hide its native button. Tapping that button
sends `back_button/pressed` back to us, so the navigation stays ours.
*/
interface Registration {
bus: ExternalMessaging;
back: () => void;
}
// Top bars that currently offer a back action. The last one to register owns
// the app's back button, so a page mounted on top of another one wins.
const registrations: Registration[] = [];
// The bus we last told to show the button, so we only report changes.
let shownOn: ExternalMessaging | undefined;
const sync = (): void => {
const active = registrations[registrations.length - 1];
if (active) {
if (shownOn !== active.bus) {
active.bus.fireMessage({ type: "back_button/show" });
shownOn = active.bus;
}
return;
}
if (shownOn) {
shownOn.fireMessage({ type: "back_button/hide" });
shownOn = undefined;
}
};
/**
* Run the back action of the top bar that currently owns the app's back
* button. Returns false when no top bar claims one, so the app can be told
* that its button was out of date.
*/
export const handleNativeBackButtonPressed = (): boolean => {
const active = registrations[registrations.length - 1];
if (!active) {
return false;
}
active.back();
return true;
};
interface NativeBackButtonHost extends ReactiveControllerHost {
hass?: HomeAssistant;
}
interface NativeBackButtonOptions {
/** Whether the top bar wants to offer a back action right now. */
visible: () => boolean;
/** Navigates back. Called for both our own arrow and the app's button. */
back: () => void;
}
/**
* Hands the back button of a top bar over to the external app when it renders
* one itself. Hosts must not render their own arrow while `native` is true.
*/
export class NativeBackButtonController implements ReactiveController {
private _registration?: Registration;
constructor(
private _host: NativeBackButtonHost,
private _options: NativeBackButtonOptions
) {
_host.addController(this);
}
/** True while the app renders the back button instead of us. */
public get native(): boolean {
return this._bus !== undefined;
}
private get _bus(): ExternalMessaging | undefined {
// Demo and gallery hosts get by with a partial hass, so tread carefully.
const external = this._host.hass?.auth?.external;
return external?.config.hasNativeBackButton ? external : undefined;
}
public hostConnected(): void {
this._sync();
}
public hostUpdated(): void {
this._sync();
}
public hostDisconnected(): void {
this._unregister();
}
private _sync(): void {
const bus = this._bus;
if (!bus || !this._options.visible()) {
this._unregister();
return;
}
if (this._registration) {
this._registration.bus = bus;
this._registration.back = this._options.back;
} else {
this._registration = { bus, back: this._options.back };
registrations.push(this._registration);
}
sync();
}
private _unregister(): void {
if (!this._registration) {
return;
}
const index = registrations.indexOf(this._registration);
if (index !== -1) {
registrations.splice(index, 1);
}
this._registration = undefined;
sync();
}
}
-18
View File
@@ -218,14 +218,6 @@ interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
type: "frontend/reload_and_clear_cache";
}
interface EMOutgoingMessageBackButtonShow extends EMMessage {
type: "back_button/show"; // The top bar offers a back action; only sent with hasNativeBackButton
}
interface EMOutgoingMessageBackButtonHide extends EMMessage {
type: "back_button/hide";
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
@@ -236,8 +228,6 @@ type EMOutgoingMessageWithoutAnswer =
| EMMessageResultSuccess
| EMOutgoingMessageAppConfiguration
| EMOutgoingMessageAssistShow
| EMOutgoingMessageBackButtonHide
| EMOutgoingMessageBackButtonShow
| EMOutgoingMessageBarCodeClose
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
@@ -357,12 +347,6 @@ export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
command: "improv/device_setup_done";
}
export interface EMIncomingMessageBackButtonPressed {
id: number;
type: "command";
command: "back_button/pressed";
}
export interface EMIncomingMessageKioskModeSet {
id: number;
type: "command";
@@ -385,7 +369,6 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
}
export type EMIncomingMessageCommands =
| EMIncomingMessageBackButtonPressed
| EMIncomingMessageRestart
| EMIncomingMessageNavigate
| EMIncomingMessageShowNotifications
@@ -420,7 +403,6 @@ export interface ExternalConfig {
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
hasNativeBackButton?: boolean; // App draws the back button of the top bar itself, driven by back_button/show and back_button/hide
}
export interface ExternalEntityAddToAction {
+9 -18
View File
@@ -2,22 +2,6 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
/**
* Navigate back the way the toolbar back arrow does, without a click to go by.
* Used by the external app when it renders the back button itself.
*/
export const navigateBack = (
backPath?: string,
backCallback?: () => void
): void => {
if (backCallback) {
backCallback();
return;
}
goBack(sanitizeNavigationPath(backPath));
};
/**
* Shared behavior of the toolbar back arrow. The arrow is a link to the
* declared parent page so it can be opened in a new tab, but a plain click
@@ -28,12 +12,19 @@ export const handleBackClick = (
backPath?: string,
backCallback?: () => void
): void => {
const path = sanitizeNavigationPath(backPath);
// Ctrl, cmd and shift click open the parent in a new tab or window: let
// the anchor handle those. A plain click is handled here instead, and
// isNavigationClick calls preventDefault so the anchor stays inert.
if (sanitizeNavigationPath(backPath) && !isNavigationClick(ev)) {
if (path && !isNavigationClick(ev)) {
return;
}
navigateBack(backPath, backCallback);
if (backCallback) {
backCallback();
return;
}
goBack(path);
};
+9 -24
View File
@@ -1,13 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { getHistoryState } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -32,18 +31,6 @@ class HassSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
@@ -51,16 +38,14 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
!this._showsBackButton
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
+8 -23
View File
@@ -17,8 +17,7 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -95,18 +94,6 @@ export class HassTabsSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
@@ -187,16 +174,14 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
!this._showsBackButton
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -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>
@@ -1,27 +1,13 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
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
>`,
});
@@ -180,77 +109,32 @@ class DialogSystemLogDetail extends LitElement {
id="copy"
@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="info">
<wa-skeleton effect="sheen"></wa-skeleton>
</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" 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
@@ -262,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
>${
@@ -276,7 +160,7 @@ class DialogSystemLogDetail extends LitElement {
)}
target="_blank"
rel="noreferrer"
>${this._i18n.localize(
>${this.hass.localize(
"ui.panel.config.logs.detail.issues"
)}</a
>`
@@ -291,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,
@@ -308,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>
${
@@ -336,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> {
@@ -382,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" +
@@ -391,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"),
});
}
@@ -399,6 +252,10 @@ class DialogSystemLogDetail extends LitElement {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0px;
}
a {
color: var(--primary-color);
}
@@ -411,21 +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);
}
wa-skeleton {
height: 1em;
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
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>
@@ -16,7 +16,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-top-app-bar-fixed";
import { NativeBackButtonController } from "../../external_app/external_back_button";
import "../../components/media-player/ha-media-manage-button";
import "../../components/media-player/ha-media-player-browse";
import type {
@@ -90,16 +89,11 @@ class PanelMediaBrowser extends LitElement {
@query("ha-bar-media-player") private _player!: BarMediaPlayer;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._navigateIds.length > 1,
back: () => this._goBack(),
});
protected render(): TemplateResult {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${
this._navigateIds.length > 1 && !this._nativeBackButton.native
this._navigateIds.length > 1
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
+15 -26
View File
@@ -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
-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,
};
@@ -7,7 +7,6 @@ import {
handleExternalMessage,
addExternalBarCodeListener,
} from "../../src/external_app/external_app_entrypoint";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import { showAutomationEditor } from "../../src/data/automation";
import type {
EMIncomingMessageRestart,
@@ -20,7 +19,6 @@ import type {
EMIncomingMessageImprovDeviceSetupDone,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBackButtonPressed,
} from "../../src/external_app/external_messaging";
vi.mock("../../src/common/dom/fire_event", () => ({
@@ -32,9 +30,6 @@ vi.mock("../../src/common/navigate", () => ({
vi.mock("../../src/data/automation", () => ({
showAutomationEditor: vi.fn(),
}));
vi.mock("../../src/external_app/external_back_button", () => ({
handleNativeBackButtonPressed: vi.fn(),
}));
describe("handleExternalMessage", () => {
let hassMainEl: any;
@@ -290,38 +285,4 @@ describe("handleExternalMessage", () => {
});
expect(result).toBe(true);
});
it("handles back_button/pressed command", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(true);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 13,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(handleNativeBackButtonPressed).toHaveBeenCalledOnce();
expect(fireMessage).toHaveBeenCalledWith({
id: 13,
type: "result",
success: true,
result: null,
});
expect(result).toBe(true);
});
it("reports back_button/pressed without a back button as an error", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(false);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 14,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
id: 14,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
expect(result).toBe(true);
});
});
@@ -1,157 +0,0 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import type { ExternalMessaging } from "../../src/external_app/external_messaging";
import type { HomeAssistant } from "../../src/types";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/common/navigate", () => ({
getHistoryState: () => undefined,
goBack: vi.fn(),
}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
await import("../../src/layouts/hass-subpage");
const { goBack } = await import("../../src/common/navigate");
let fireMessage: ReturnType<typeof vi.fn>;
let nativeBus: ExternalMessaging;
// The app has a single external bus, so every page shares one.
const makeHass = (hasNativeBackButton: boolean): HomeAssistant =>
({
auth: {
external: hasNativeBackButton
? nativeBus
: ({
config: {},
fireMessage,
} as unknown as ExternalMessaging),
},
}) as HomeAssistant;
let host: HTMLDivElement | undefined;
const mount = async (hass: HomeAssistant, backPath = "/config") => {
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, backPath });
host!.append(element);
await (element as LitElement).updateComplete;
return element;
};
const arrowOf = (element: Element) =>
element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
beforeEach(() => {
fireMessage = vi.fn();
nativeBus = {
config: { hasNativeBackButton: true },
fireMessage,
} as unknown as ExternalMessaging;
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("native back button", () => {
it("keeps rendering the arrow when the app has no native back button", async () => {
const element = await mount(makeHass(false));
expect(arrowOf(element)).not.toBeNull();
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("hides the arrow and reports the back button to the app", async () => {
const element = await mount(makeHass(true));
expect(arrowOf(element)).toBeNull();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
});
it("hides the app back button once the page is gone", async () => {
const element = await mount(makeHass(true));
fireMessage.mockClear();
element.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/hide",
});
});
it("does not report a back button on a main page", async () => {
const hass = makeHass(true);
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, mainPage: true });
host!.append(element);
await (element as LitElement).updateComplete;
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("navigates back when the app reports a press", async () => {
await mount(makeHass(true), "/config/areas");
expect(handleNativeBackButtonPressed()).toBe(true);
expect(goBack).toHaveBeenCalledWith("/config/areas");
});
it("uses the back callback of the page when it has one", async () => {
const backCallback = vi.fn();
const element = await mount(makeHass(true));
Object.assign(element, { backCallback });
await (element as LitElement).updateComplete;
expect(handleNativeBackButtonPressed()).toBe(true);
expect(backCallback).toHaveBeenCalledOnce();
expect(goBack).not.toHaveBeenCalled();
});
it("lets the page mounted last own the back button", async () => {
const first = vi.fn();
const second = vi.fn();
const firstPage = await mount(makeHass(true));
Object.assign(firstPage, { backCallback: first });
await (firstPage as LitElement).updateComplete;
const secondPage = await mount(makeHass(true));
Object.assign(secondPage, { backCallback: second });
await (secondPage as LitElement).updateComplete;
handleNativeBackButtonPressed();
expect(second).toHaveBeenCalledOnce();
expect(first).not.toHaveBeenCalled();
// Back on the first page, it owns the button again.
secondPage.remove();
handleNativeBackButtonPressed();
expect(first).toHaveBeenCalledOnce();
});
it("only reports the back button once while pages come and go", async () => {
const firstPage = await mount(makeHass(true));
const secondPage = await mount(makeHass(true));
firstPage.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
secondPage.remove();
expect(fireMessage).toHaveBeenLastCalledWith({ type: "back_button/hide" });
});
});