mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 14:33:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56e4d155c | ||
|
|
2e28098b73 | ||
|
|
a5a5ac6a1c |
@@ -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";
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -81,7 +81,7 @@ type SystemHealthEvent =
|
||||
| SystemHealthEventFinish;
|
||||
|
||||
export const subscribeSystemHealthInfo = (
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
hass: HomeAssistant,
|
||||
callback: (info: SystemHealthInfo | undefined) => void
|
||||
) => {
|
||||
let data = {};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)}`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user