Compare commits

..
Author SHA1 Message Date
Aidan Timson 2e4282ca7c Validate security alert card configuration 2026-08-24 13:55:11 +01:00
Aidan Timson f785e96e24 Fix security alert cards 2026-08-24 13:55:11 +01:00
Aidan Timson 20b7414682 Add security alerts card 2026-08-24 13:55:11 +01:00
16 changed files with 1449 additions and 65 deletions
+2 -2
View File
@@ -152,7 +152,7 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -176,7 +176,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.0",
"eslint": "10.8.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
-3
View File
@@ -304,9 +304,6 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
cover: {
speed: "supported_speeds",
},
event: {
event_type: "event_types",
},
@@ -470,19 +470,15 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const canPickCurrent =
currentItem?.can_play ||
(currentItem && this.accept?.includes("directory"));
return html`
${
canPickCurrent || showSearch
currentItem.can_play || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !canPickCurrent,
"search-only": !currentItem.can_play,
})}"
@transitionend=${this._setHeaderHeight}
>
@@ -495,7 +491,7 @@ export class HaMediaPlayerBrowse extends LitElement {
: nothing
}
${
canPickCurrent
currentItem.can_play
? html`<div class="header-content">
${
currentItem.thumbnail
@@ -507,7 +503,7 @@ export class HaMediaPlayerBrowse extends LitElement {
></ha-media-browser-thumbnail>
${
this.narrow &&
canPickCurrent &&
currentItem?.can_play &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
@@ -550,7 +546,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
</div>
${
canPickCurrent &&
currentItem.can_play &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
@@ -0,0 +1,5 @@
import { createContext } from "@lit/context";
import type { SecurityAlertItem } from "./helpers";
export const securityAlertsContext =
createContext<SecurityAlertItem[]>("security-alerts");
@@ -0,0 +1,203 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiCctvOff, mdiLockOpen, mdiShieldAlert, mdiWater } from "@mdi/js";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { isValidEntityId } from "../../../../common/entity/valid_entity_id";
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
import type { HomeAssistant } from "../../../../types";
import type { StateCondition } from "../../common/validate-condition";
import type { SecurityAlertsCardEntityConfig } from "../types";
import {
checkConditionsMet,
extractConditionEntityIds,
validateConditionalConfig,
} from "../../common/validate-condition";
export interface SecurityAlertItem {
entityId: string;
stateObj: HassEntity;
color?: string;
pulse: boolean;
icon?: string;
iconPath?: string;
}
type SecurityAlertIcon = Pick<SecurityAlertItem, "icon" | "iconPath">;
export type SecurityAlertHass = Pick<
HomeAssistant,
"config" | "locale" | "states" | "user"
>;
const WARNING_BINARY_SENSOR_DEVICE_CLASSES = [
"door",
"garage_door",
"lock",
"opening",
"tamper",
"window",
] as const;
type WarningBinarySensorDeviceClass =
(typeof WARNING_BINARY_SENSOR_DEVICE_CLASSES)[number];
const WARNING_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<WarningBinarySensorDeviceClass>(WARNING_BINARY_SENSOR_DEVICE_CLASSES);
const isWarningBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is WarningBinarySensorDeviceClass =>
WARNING_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as WarningBinarySensorDeviceClass
);
export const computeSecurityAlertEntityDefaultColor = (
stateObj?: HassEntity
): string => {
if (!stateObj) {
return "red";
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "camera") {
return "blue";
}
if (domain === "binary_sensor") {
const deviceClass = stateObj.attributes.device_class;
return typeof deviceClass === "string" &&
isWarningBinarySensorDeviceClass(deviceClass)
? "amber"
: "red";
}
if (domain === "cover" || domain === "lock") {
return "amber";
}
return "red";
};
export const computeDefaultSecurityAlertVisibility = (
entityId: string
): StateCondition[] => {
const condition: StateCondition = {
condition: "state",
entity: entityId,
};
switch (computeDomain(entityId)) {
case "alarm_control_panel":
condition.state = "triggered";
break;
case "camera":
condition.state = [UNAVAILABLE, UNKNOWN];
break;
case "cover":
condition.state_not = "closed";
break;
case "lock":
condition.state_not = "locked";
break;
default:
condition.state = "on";
}
return [condition];
};
export const isValidSecurityAlertEntityConfig = (
alertEntity: unknown
): alertEntity is SecurityAlertsCardEntityConfig =>
Boolean(
alertEntity &&
typeof alertEntity === "object" &&
"entity" in alertEntity &&
typeof alertEntity.entity === "string" &&
isValidEntityId(alertEntity.entity) &&
(!("color" in alertEntity) ||
alertEntity.color === undefined ||
typeof alertEntity.color === "string") &&
(!("pulse" in alertEntity) ||
alertEntity.pulse === undefined ||
typeof alertEntity.pulse === "boolean") &&
(!("visibility" in alertEntity) ||
alertEntity.visibility === undefined ||
(Array.isArray(alertEntity.visibility) &&
alertEntity.visibility.every(
(condition) => condition?.condition === "state"
) &&
validateConditionalConfig(alertEntity.visibility)))
);
export const extractSecurityAlertEntityIds = (
alertEntities: SecurityAlertsCardEntityConfig[]
): string[] => [
...new Set(
alertEntities.flatMap((alertEntity) => [
alertEntity.entity,
...extractConditionEntityIds(
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
),
])
),
];
const computeSecurityAlertIcon = (stateObj: HassEntity): SecurityAlertIcon => {
const domain = computeDomain(stateObj.entity_id);
if (stateObj.state === UNAVAILABLE && domain === "camera") {
return { iconPath: mdiCctvOff };
}
if (
domain === "binary_sensor" &&
stateObj.attributes.device_class === "moisture"
) {
return { iconPath: mdiWater };
}
if (domain === "lock") {
return { iconPath: mdiLockOpen };
}
if (domain === "alarm_control_panel") {
return { iconPath: mdiShieldAlert };
}
return typeof stateObj.attributes.icon === "string"
? { icon: stateObj.attributes.icon }
: {};
};
export const computeSecurityAlertItem = (
stateObj: HassEntity,
alertEntity: SecurityAlertsCardEntityConfig
): SecurityAlertItem => ({
entityId: stateObj.entity_id,
stateObj,
color: alertEntity.color ?? computeSecurityAlertEntityDefaultColor(stateObj),
pulse: alertEntity.pulse === undefined || alertEntity.pulse === true,
...computeSecurityAlertIcon(stateObj),
});
export const computeSecurityAlertItems = (
hass: SecurityAlertHass,
alertEntities: SecurityAlertsCardEntityConfig[]
): SecurityAlertItem[] =>
alertEntities
.map((alertEntity): SecurityAlertItem | undefined => {
const stateObj = hass.states[alertEntity.entity];
if (!stateObj) {
return undefined;
}
const visibility =
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity);
// checkConditionsMet only reads config, locale, states, and user for
// supported condition types. Keep this helper narrowed to avoid
// reconstructing a full HomeAssistant object from card contexts.
if (
!checkConditionsMet(visibility, hass as HomeAssistant, {
entity_id: alertEntity.entity,
})
) {
return undefined;
}
return computeSecurityAlertItem(stateObj, alertEntity);
})
.filter((item): item is SecurityAlertItem => Boolean(item));
@@ -0,0 +1,241 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../../common/color/compute-color";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-card";
import "../../../../components/ha-relative-time";
import "../../../../components/ha-state-icon";
import "../../../../components/tile/ha-tile-container";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import {
configContext,
formattersContext,
internationalizationContext,
} from "../../../../data/context";
import type { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
import { tileCardStyle } from "../tile/tile-card-style";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { SecurityAlertCardConfig } from "../types";
import {
computeSecurityAlertItem,
computeSecurityAlertItems,
extractSecurityAlertEntityIds,
isValidSecurityAlertEntityConfig,
type SecurityAlertItem,
} from "./helpers";
@customElement("hui-security-alert-card")
export class HuiSecurityAlertCard extends LitElement implements LovelaceCard {
public connectedWhileHidden = true;
@property({ attribute: false }) public alert?: SecurityAlertItem;
@property({ type: Boolean }) public preview = false;
@state() private _config?: SecurityAlertCardConfig;
@state() private _alertEntityIds?: string[];
@state()
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
private _states?: Record<string, HassEntity>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
public setConfig(config: SecurityAlertCardConfig): void {
if (!isValidSecurityAlertEntityConfig(config)) {
throw new Error("Invalid configuration");
}
this._config = config;
this._alertEntityIds = extractSecurityAlertEntityIds([config]);
}
public getCardSize(): number {
return 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
};
}
private get _visibleAlert(): SecurityAlertItem | undefined {
if (this.alert) {
return this.alert;
}
if (!this._config || !this._alertEntityIds?.length || !this._states) {
return undefined;
}
if (this.preview) {
const stateObj = this._states[this._config.entity];
return stateObj
? computeSecurityAlertItem(stateObj, this._config)
: undefined;
}
return computeSecurityAlertItems(
{ ...this._hassConfig, ...this._i18n, states: this._states },
[this._config]
)[0];
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this._config) {
return;
}
const shouldBeHidden = !this.preview && !this._visibleAlert;
if (shouldBeHidden !== this.hidden) {
this.style.display = shouldBeHidden ? "none" : "";
this.toggleAttribute("hidden", shouldBeHidden);
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
}
}
protected render() {
const alert = this._visibleAlert;
if (!alert || !this._formatters || (this._config && this.hidden)) {
return nothing;
}
const stateDisplay = this._formatters.formatEntityState(alert.stateObj);
const pulse = alert.pulse === true;
const hasColor = alert.color !== undefined && alert.color !== "none";
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-security-alert-color":
alert.color && hasColor ? computeCssColor(alert.color) : undefined,
"--ha-security-alert-static-opacity": pulse
? undefined
: "var(--ha-security-alert-pulse-opacity)",
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
@action=${this._handleAction}
>
<ha-tile-icon
slot="icon"
.icon=${alert.icon}
.iconPath=${alert.iconPath}
>
${
!alert.icon && !alert.iconPath
? html`<ha-state-icon
slot="icon"
.stateObj=${alert.stateObj}
></ha-state-icon>`
: nothing
}
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">${computeStateName(alert.stateObj)}</span>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${alert.stateObj.last_changed}
></ha-relative-time>
</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
const alert = this._visibleAlert;
if (ev.detail.action === "tap" && alert) {
fireEvent(this, "hass-more-info", { entityId: alert.entityId });
}
}
static styles = [
tileCardStyle,
css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-pulse-opacity, 0.3);
}
}
:host {
display: block;
--ha-security-alert-pulse-duration: 1s;
--ha-security-alert-pulse-opacity: 0.3;
--ha-security-alert-static-opacity: 0;
}
ha-card {
position: relative;
overflow: hidden;
height: 100%;
}
ha-card:not(.no-color) {
--tile-color: var(--ha-security-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
}
ha-card::before {
position: absolute;
inset: 0;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: var(--ha-security-alert-color);
content: "";
opacity: var(--ha-security-alert-static-opacity);
pointer-events: none;
}
ha-card.pulse::before {
--ha-pulse-opacity: var(--ha-security-alert-pulse-opacity);
animation: pulse-opacity var(--ha-security-alert-pulse-duration)
ease-in-out infinite alternate;
}
ha-card:not(.pulse)::before {
animation: none;
}
ha-tile-container {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
animation: none;
opacity: var(--ha-security-alert-pulse-opacity);
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alert-card": HuiSecurityAlertCard;
}
}
@@ -0,0 +1,146 @@
import { ContextProvider, consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
configContext,
internationalizationContext,
} from "../../../../data/context";
import {
computeSecurityAlertItem,
computeSecurityAlertItems,
extractSecurityAlertEntityIds,
isValidSecurityAlertEntityConfig,
type SecurityAlertItem,
} from "./helpers";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { SecurityAlertsCardConfig } from "../types";
import { securityAlertsContext } from "./context";
import "./hui-security-alerts-heading";
import "./hui-security-alerts-list";
@customElement("hui-security-alerts-card")
export class HuiSecurityAlertsCard extends LitElement implements LovelaceCard {
public connectedWhileHidden = true;
@property({ type: Boolean }) public preview = false;
private _alertsProvider = new ContextProvider<{
__context__: SecurityAlertItem[];
}>(this, {
context: securityAlertsContext,
initialValue: [],
});
@state() private _config?: SecurityAlertsCardConfig;
@state() private _alertEntityIds?: string[];
@state()
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
private _states?: Record<string, HassEntity>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
public setConfig(config: SecurityAlertsCardConfig): void {
if (
!Array.isArray(config.alert_entities) ||
config.alert_entities.some(
(alertEntity) => !isValidSecurityAlertEntityConfig(alertEntity)
)
) {
throw new Error("Invalid configuration");
}
this._config = config;
this._alertEntityIds = extractSecurityAlertEntityIds(config.alert_entities);
}
public getCardSize(): number {
return this._visibleAlerts.length + 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: "auto",
min_columns: 6,
min_rows: 1,
};
}
private get _visibleAlerts(): SecurityAlertItem[] {
const states = this._states;
if (!this._config || !this._alertEntityIds?.length || !states) {
return [];
}
if (this.preview) {
return this._config.alert_entities
.map((alertEntity) => {
const stateObj = states[alertEntity.entity];
return stateObj
? computeSecurityAlertItem(stateObj, alertEntity)
: undefined;
})
.filter((item): item is SecurityAlertItem => Boolean(item));
}
return computeSecurityAlertItems(
{ ...this._hassConfig, ...this._i18n, states },
this._config.alert_entities
);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config) {
return;
}
const alerts = this._visibleAlerts;
this._alertsProvider.setValue(alerts);
const shouldBeHidden = !this.preview && alerts.length === 0;
if (shouldBeHidden !== this.hidden) {
this.style.display = shouldBeHidden ? "none" : "";
this.toggleAttribute("hidden", shouldBeHidden);
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
this.requestUpdate();
}
}
protected render() {
if (!this._config || this.hidden) {
return nothing;
}
return html`
${
this.preview
? nothing
: html`<hui-security-alerts-heading></hui-security-alerts-heading>`
}
<hui-security-alerts-list></hui-security-alerts-list>
`;
}
static styles = css`
:host {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-card": HuiSecurityAlertsCard;
}
}
@@ -0,0 +1,54 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SecurityAlertItem } from "./helpers";
import { securityAlertsContext } from "./context";
@customElement("hui-security-alerts-heading")
export class HuiSecurityAlertsHeading extends LitElement {
@state()
@consume({ context: securityAlertsContext, subscribe: true })
private _alerts: SecurityAlertItem[] = [];
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
if (!this._alerts.length) {
return nothing;
}
return html`<h2>${this._localize("ui.card.security-alerts.title")}</h2>`;
}
static styles = css`
:host {
display: block;
min-height: 24px;
padding: 0 var(--ha-space-1);
}
h2 {
color: var(--ha-heading-card-title-color, var(--primary-text-color));
font-size: var(--ha-heading-card-title-font-size, var(--ha-font-size-l));
font-weight: var(
--ha-heading-card-title-font-weight,
var(--ha-font-weight-normal)
);
line-height: var(
--ha-heading-card-title-line-height,
var(--ha-line-height-normal)
);
letter-spacing: 0.1px;
margin: 0 0 var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-heading": HuiSecurityAlertsHeading;
}
}
@@ -0,0 +1,46 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { SecurityAlertItem } from "./helpers";
import { securityAlertsContext } from "./context";
import "./hui-security-alert-card";
@customElement("hui-security-alerts-list")
export class HuiSecurityAlertsList extends LitElement {
@state()
@consume({ context: securityAlertsContext, subscribe: true })
private _alerts: SecurityAlertItem[] = [];
protected render() {
if (!this._alerts.length) {
return nothing;
}
return html`
<div class="alerts">
${this._alerts.map(
(alert) => html`
<hui-security-alert-card .alert=${alert}></hui-security-alert-card>
`
)}
</div>
`;
}
static styles = css`
:host {
display: block;
}
.alerts {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-security-alerts-list": HuiSecurityAlertsList;
}
}
+24 -1
View File
@@ -23,7 +23,11 @@ import type {
LovelaceCardFeaturePosition,
} from "../card-features/types";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type { Condition, LegacyCondition } from "../common/validate-condition";
import type {
Condition,
LegacyCondition,
StateCondition,
} from "../common/validate-condition";
import type { HuiImage } from "../components/hui-image";
import type { LogbookNameDetail } from "../../logbook/logbook-entry-model";
import type { TimestampRenderingFormat } from "../components/types";
@@ -718,6 +722,25 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface SecurityAlertsCardEntityConfig {
entity: string;
color?: string;
pulse?: boolean;
visibility?: StateCondition[];
}
export interface SecurityAlertCardConfig
extends
Omit<LovelaceCardConfig, "visibility">,
SecurityAlertsCardEntityConfig {
type: "security-alert";
}
export interface SecurityAlertsCardConfig extends LovelaceCardConfig {
type: "security-alerts";
alert_entities: SecurityAlertsCardEntityConfig[];
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
title: string;
entities: string[];
@@ -80,6 +80,10 @@ const LAZY_LOAD_TYPES = {
shortcut: () => import("../cards/hui-shortcut-card"),
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
repairs: () => import("../cards/hui-repairs-card"),
"security-alert": () =>
import("../cards/security-alerts/hui-security-alert-card"),
"security-alerts": () =>
import("../cards/security-alerts/hui-security-alerts-card"),
updates: () => import("../cards/hui-updates-card"),
gauge: () => import("../cards/hui-gauge-card"),
"history-graph": () => import("../cards/hui-history-graph-card"),
+3
View File
@@ -256,6 +256,9 @@
"count_updates": "{count} {count, plural,\n one {update available}\n other {updates available}\n}",
"no_updates": "Up to date"
},
"security-alerts": {
"title": "Active alerts"
},
"media_player": {
"source": "Source",
"sound_mode": "Sound mode",
@@ -0,0 +1,240 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { afterEach, describe, expect, it } from "vitest";
import type {
SecurityAlertHass,
SecurityAlertItem,
} from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
import type { SecurityAlertCardConfig } from "../../../../../src/panels/lovelace/cards/types";
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alert-card";
interface TestSecurityAlertCard extends HTMLElement {
updateComplete: Promise<boolean>;
alert?: SecurityAlertItem;
preview: boolean;
setConfig(config: SecurityAlertCardConfig): void;
getCardSize(): number;
getGridOptions(): {
columns?: number | "full";
rows?: number | "auto";
min_columns?: number;
min_rows?: number;
};
_states?: Record<string, HassEntity>;
_hassConfig: Pick<SecurityAlertHass, "config" | "user">;
_i18n: Pick<SecurityAlertHass, "locale">;
_formatters: {
formatEntityState: (stateObj: HassEntity) => string;
};
}
const state = (value = "on"): HassEntity => ({
entity_id: "binary_sensor.window",
state: value,
attributes: {
device_class: "window",
friendly_name: "Window",
},
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
context: { id: "", parent_id: null, user_id: null },
});
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
const stateObj = state();
return {
entityId: stateObj.entity_id,
stateObj,
pulse,
color,
};
};
const createCard = async (item: SecurityAlertItem) => {
const element = document.createElement(
"hui-security-alert-card"
) as unknown as TestSecurityAlertCard;
element.alert = item;
element._formatters = { formatEntityState: () => "On" };
document.body.appendChild(element);
await element.updateComplete;
return element;
};
const createConfiguredCard = async (
config: SecurityAlertCardConfig,
stateObj = state(),
preview = false
) => {
const element = document.createElement(
"hui-security-alert-card"
) as unknown as TestSecurityAlertCard;
element.setConfig(config);
element.preview = preview;
element._states = { [stateObj.entity_id]: stateObj };
element._hassConfig = {
config: { time_zone: "UTC" } as SecurityAlertHass["config"],
user: undefined,
};
element._i18n = {
locale: { time_zone: "server" } as SecurityAlertHass["locale"],
};
element._formatters = { formatEntityState: () => "On" };
document.body.appendChild(element);
await element.updateComplete;
await element.updateComplete;
return element;
};
const config = (
overrides: Partial<SecurityAlertCardConfig> = {}
): SecurityAlertCardConfig => ({
type: "security-alert",
entity: "binary_sensor.window",
...overrides,
});
describe("hui-security-alert-card", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("validates manual state-only configuration", () => {
const element = document.createElement("hui-security-alert-card");
expect(() =>
element.setConfig(
config({
visibility: [
{
condition: "state",
entity: "binary_sensor.window",
state: "on",
},
],
})
)
).not.toThrow();
expect(() =>
element.setConfig({
type: "security-alert",
entity: "binary_sensor.window",
visibility: [{ condition: "screen", media_query: "(min-width: 1px)" }],
} as unknown as SecurityAlertCardConfig)
).toThrow("Invalid configuration");
expect(() =>
element.setConfig({
type: "security-alert",
entity: "binary_sensor.window",
visibility: "on",
} as unknown as SecurityAlertCardConfig)
).toThrow("Invalid configuration");
});
it("renders from manual configuration", async () => {
const element = await createConfiguredCard(
config({ color: "amber", pulse: false })
);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(false);
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
"var(--amber-color)"
);
expect(element.getCardSize()).toBe(1);
expect(element.getGridOptions()).toEqual({
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
});
});
it("hides when manual visibility does not match", async () => {
const element = document.createElement(
"hui-security-alert-card"
) as unknown as TestSecurityAlertCard;
let visible: boolean | undefined;
element.addEventListener("card-visibility-changed", (ev) => {
visible = ev.detail.value;
});
element.setConfig(config());
element._states = { "binary_sensor.window": state("off") };
element._hassConfig = {
config: { time_zone: "UTC" } as SecurityAlertHass["config"],
user: undefined,
};
element._i18n = {
locale: { time_zone: "server" } as SecurityAlertHass["locale"],
};
element._formatters = { formatEntityState: () => "Off" };
document.body.appendChild(element);
await element.updateComplete;
await element.updateComplete;
expect(element.hidden).toBe(true);
expect(element.shadowRoot!.querySelector("ha-card")).toBeNull();
expect(visible).toBe(false);
});
it("renders non-matching manual configuration in preview", async () => {
const element = await createConfiguredCard(config(), state("off"), true);
expect(element.hidden).toBe(false);
expect(element.shadowRoot!.querySelector("ha-card")).not.toBeNull();
});
it("does not pulse when pulse is disabled", async () => {
const element = await createCard(alert(false));
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(false);
expect(
card.style.getPropertyValue("--ha-security-alert-static-opacity")
).toBe("var(--ha-security-alert-pulse-opacity)");
});
it("pulses when pulse is enabled", async () => {
const element = await createCard(alert(true));
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(true);
});
it("applies configured colors", async () => {
const element = await createCard(alert(true, "amber"));
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("warning")).toBe(false);
expect(card.classList.contains("no-color")).toBe(false);
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
"var(--amber-color)"
);
});
it("does not apply a color when color is none", async () => {
const element = await createCard(alert(true, "none"));
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("warning")).toBe(false);
expect(card.classList.contains("no-color")).toBe(true);
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe("");
});
it("opens more info on tap", async () => {
const element = await createCard(alert(true));
let entityId: string | undefined;
element.addEventListener("hass-more-info", (ev) => {
entityId = ev.detail.entityId;
});
element.shadowRoot!.querySelector("ha-tile-container")!.dispatchEvent(
new CustomEvent("action", {
bubbles: true,
composed: true,
detail: { action: "tap" },
})
);
expect(entityId).toBe("binary_sensor.window");
});
});
@@ -0,0 +1,85 @@
import { ContextProvider } from "@lit/context";
import { afterEach, describe, expect, it } from "vitest";
import { securityAlertsContext } from "../../../../../src/panels/lovelace/cards/security-alerts/context";
import type { SecurityAlertItem } from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-list";
interface TestSecurityAlertsList extends HTMLElement {
updateComplete: Promise<boolean>;
}
interface TestSecurityAlertCard extends HTMLElement {
updateComplete: Promise<boolean>;
alert: SecurityAlertItem;
_formatters: {
formatEntityState: () => string;
};
}
const state = () => ({
entity_id: "binary_sensor.window",
state: "on",
attributes: {
device_class: "window",
friendly_name: "Window",
},
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
context: { id: "", parent_id: null, user_id: null },
});
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
const stateObj = state();
return {
entityId: stateObj.entity_id,
stateObj,
pulse,
color,
};
};
const createList = async (alerts: SecurityAlertItem[]) => {
const host = document.createElement("div");
document.body.appendChild(host);
const provider = new ContextProvider(host, {
context: securityAlertsContext,
initialValue: alerts,
});
const element = document.createElement(
"hui-security-alerts-list"
) as unknown as TestSecurityAlertsList;
host.appendChild(element);
await element.updateComplete;
return { element, provider };
};
describe("hui-security-alerts-list", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("renders standalone cards from alert context", async () => {
const alerts = [alert(true, "amber"), alert(false, "none")];
const { element, provider } = await createList(alerts);
let cards = element.shadowRoot!.querySelectorAll(
"hui-security-alert-card"
) as unknown as NodeListOf<TestSecurityAlertCard>;
cards.forEach((card) => {
card._formatters = { formatEntityState: () => "On" };
});
await Promise.all(Array.from(cards, (card) => card.updateComplete));
expect(cards).toHaveLength(2);
expect(cards[0].alert).toBe(alerts[0]);
expect(cards[1].alert).toBe(alerts[1]);
const replacementAlert = alert(true, "red");
provider.setValue([replacementAlert]);
await element.updateComplete;
cards = element.shadowRoot!.querySelectorAll(
"hui-security-alert-card"
) as unknown as NodeListOf<TestSecurityAlertCard>;
expect(cards).toHaveLength(1);
expect(cards[0].alert).toBe(replacementAlert);
});
});
@@ -0,0 +1,341 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import {
computeDefaultSecurityAlertVisibility,
computeSecurityAlertEntityDefaultColor,
computeSecurityAlertItems,
type SecurityAlertHass,
} from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
import type { SecurityAlertsCardConfig } from "../../../../../src/panels/lovelace/cards/types";
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-card";
const state = (
entityId: string,
value: string,
deviceClass: string | undefined,
lastChanged: string
): HassEntity => ({
entity_id: entityId,
state: value,
attributes: {
...(deviceClass ? { device_class: deviceClass } : {}),
friendly_name: entityId,
},
last_changed: lastChanged,
last_updated: lastChanged,
context: { id: "", parent_id: null, user_id: null },
});
const hass = (states: Record<string, HassEntity>): SecurityAlertHass => ({
states,
user: undefined,
config: {
time_zone: "UTC",
} as SecurityAlertHass["config"],
locale: {
time_zone: "server",
} as SecurityAlertHass["locale"],
});
describe("computeDefaultSecurityAlertVisibility", () => {
it("defaults alarm panels to triggered", () => {
expect(
computeDefaultSecurityAlertVisibility("alarm_control_panel.house")
).toEqual([
{
condition: "state",
entity: "alarm_control_panel.house",
state: "triggered",
},
]);
});
it("defaults binary sensors to on", () => {
expect(computeDefaultSecurityAlertVisibility("binary_sensor.leak")).toEqual(
[
{
condition: "state",
entity: "binary_sensor.leak",
state: "on",
},
]
);
});
it("defaults locks and covers to any unsecured state", () => {
expect(computeDefaultSecurityAlertVisibility("lock.front_door")).toEqual([
{
condition: "state",
entity: "lock.front_door",
state_not: "locked",
},
]);
expect(computeDefaultSecurityAlertVisibility("cover.garage_door")).toEqual([
{
condition: "state",
entity: "cover.garage_door",
state_not: "closed",
},
]);
});
it("defaults cameras to unavailable or unknown", () => {
expect(computeDefaultSecurityAlertVisibility("camera.patio")).toEqual([
{
condition: "state",
entity: "camera.patio",
state: ["unavailable", "unknown"],
},
]);
});
});
describe("hui-security-alerts-card", () => {
const createCard = () => document.createElement("hui-security-alerts-card");
it("rejects a non-array alert entity configuration", () => {
const card = createCard();
expect(() =>
card.setConfig({
type: "security-alerts",
alert_entities: "binary_sensor.window",
} as unknown as SecurityAlertsCardConfig)
).toThrow("Invalid configuration");
});
it("rejects unsupported visibility conditions", () => {
const card = createCard();
expect(() =>
card.setConfig({
type: "security-alerts",
alert_entities: [
{
entity: "binary_sensor.window",
visibility: [
{ condition: "screen", media_query: "(min-width: 1px)" },
],
},
],
} as unknown as SecurityAlertsCardConfig)
).toThrow("Invalid configuration");
});
it.each([
{ entity: "" },
{ entity: "window" },
{ entity: "binary_sensor.window", color: 123 },
{ entity: "binary_sensor.window", pulse: "true" },
])("rejects invalid alert entity configuration %#", (alertEntity) => {
const card = createCard();
expect(() =>
card.setConfig({
type: "security-alerts",
alert_entities: [alertEntity],
} as unknown as SecurityAlertsCardConfig)
).toThrow("Invalid configuration");
});
});
describe("computeSecurityAlertEntityDefaultColor", () => {
it("uses device class defaults independent of current state", () => {
expect(
computeSecurityAlertEntityDefaultColor(
state(
"binary_sensor.carbon_monoxide",
"unavailable",
"carbon_monoxide",
"2026-01-01T00:00:00Z"
)
)
).toBe("red");
expect(
computeSecurityAlertEntityDefaultColor(
state(
"binary_sensor.window",
"unavailable",
"window",
"2026-01-01T00:00:00Z"
)
)
).toBe("amber");
expect(
computeSecurityAlertEntityDefaultColor(
state("camera.patio", "unavailable", undefined, "2026-01-01T00:00:00Z")
)
).toBe("blue");
});
});
describe("computeSecurityAlertItems", () => {
it("does not infer alerts without configured rows", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"on",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(computeSecurityAlertItems(hass(states), [])).toEqual([]);
});
it("shows configured entities when their default visibility matches", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"on",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.dishwasher_leak" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.dishwasher_leak"]);
});
it("shows carbon monoxide sensors when active", () => {
const states = {
"binary_sensor.carbon_monoxide": state(
"binary_sensor.carbon_monoxide",
"on",
"carbon_monoxide",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.carbon_monoxide" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.carbon_monoxide"]);
});
it("hides configured entities when their default visibility does not match", () => {
const states = {
"binary_sensor.dishwasher_leak": state(
"binary_sensor.dishwasher_leak",
"off",
"moisture",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.dishwasher_leak" },
])
).toEqual([]);
});
it("uses custom visibility conditions", () => {
const states = {
"lock.front_door": state(
"lock.front_door",
"unlocked",
undefined,
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{
entity: "lock.front_door",
visibility: [
{
condition: "state",
entity: "lock.front_door",
state: "unlocked",
},
],
},
]).map((item) => item.entityId)
).toEqual(["lock.front_door"]);
});
it("applies configured color and pulse", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{
entity: "binary_sensor.window",
color: "red",
pulse: false,
},
])[0]
).toMatchObject({ color: "red", pulse: false });
});
it("uses the entity default color when color is not configured", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window" },
])[0]
).toMatchObject({ color: "amber" });
});
it("keeps no color as an explicit color choice", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:00:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window", color: "none" },
])[0]
).toMatchObject({ color: "none" });
});
it("keeps configured order", () => {
const states = {
"binary_sensor.window": state(
"binary_sensor.window",
"on",
"window",
"2026-01-01T00:02:00Z"
),
"binary_sensor.leak": state(
"binary_sensor.leak",
"on",
"moisture",
"2026-01-01T00:01:00Z"
),
};
expect(
computeSecurityAlertItems(hass(states), [
{ entity: "binary_sensor.window" },
{ entity: "binary_sensor.leak" },
]).map((item) => item.entityId)
).toEqual(["binary_sensor.window", "binary_sensor.leak"]);
});
});
+50 -50
View File
@@ -4697,22 +4697,22 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/client@npm:1.6.3"
checksum: 10/407d2509dc829e8f89aada7690b63c98a1d18ecffd3e23740be5f506a043cbc16d75e1f0a80f1299b60ca07d6151b7fae848568dd53b71e211dcefc163f55dd5
"@rsdoctor/client@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/client@npm:1.6.2"
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/core@npm:1.6.3"
"@rsdoctor/core@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/core@npm:1.6.2"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/sdk": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rspack/resolver": "npm:^0.2.8"
browserslist-load-config: "npm:^1.0.2"
es-toolkit: "npm:^1.49.0"
@@ -4720,60 +4720,60 @@ __metadata:
fs-extra: "npm:^11.1.1"
semver: "npm:^7.8.5"
source-map: "npm:^0.7.6"
checksum: 10/89da82b0cf60be5560c5f0b9ac2fd8695ca2d53b0bed698202ef12182b507c8021989e4a118f27a36d6791f1e0998e9d58e44f5f564cfe03b33e4b0288324780
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/graph@npm:1.6.3"
"@rsdoctor/graph@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/graph@npm:1.6.2"
dependencies:
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
es-toolkit: "npm:^1.49.0"
path-browserify: "npm:1.0.1"
source-map: "npm:^0.7.6"
checksum: 10/b91aed0faed7771d74cd27015ee0e3c1bf81b8783d701b867328945f1533d90ac4590d0aa5da06b56126fa7caaaa0ea0dbbf66dcec2d64a1da98b436dab3f0dd
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/rspack-plugin@npm:1.6.3"
"@rsdoctor/rspack-plugin@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
dependencies:
"@rsdoctor/core": "npm:1.6.3"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/sdk": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
"@rsdoctor/core": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/3b1cc8611ba2e2047a1ac8143aa8e5749e6a19fdb021dfa33ea06ad9a7ac78cb4a025baacce3a6d659107ee612d34c7bbabfeba15784003e0efa376f4dc00071
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/sdk@npm:1.6.3"
"@rsdoctor/sdk@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/sdk@npm:1.6.2"
dependencies:
"@rsdoctor/client": "npm:1.6.3"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
"@rsdoctor/client": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
launch-editor: "npm:^2.13.2"
safer-buffer: "npm:2.1.2"
socket.io: "npm:4.8.1"
tapable: "npm:2.3.3"
checksum: 10/9363930b637869262c93913a0bc90e76c7662cebf869778300b6184e82fe41ca3af8b065c774327557b22f7e24a3ed517ff1db437f161297c11d03894e25c930
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/types@npm:1.6.3"
"@rsdoctor/types@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/types@npm:1.6.2"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -4787,16 +4787,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/b3ee6089b671ac0f2dc656f06a03ef475e982f44b34faa664bd7f9121a6ec7609d35cd814f093b7bf3326c8fce64998542fca0645fc4c0ad7e3c136c23e90a0d
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/utils@npm:1.6.3"
"@rsdoctor/utils@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/utils@npm:1.6.2"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.2"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -4810,7 +4810,7 @@ __metadata:
picocolors: "npm:^1.1.1"
rslog: "npm:^2.1.2"
strip-ansi: "npm:^7.2.0"
checksum: 10/06b18159d64809e9c764371a00e284d344c2912b17e6eebe71bd04fe6c6e5bc05bd4bd7dd5860351f3c9c434b96ff1acbaa7d0be02f0b44f6fed42ddb18748ae
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
languageName: node
linkType: hard
@@ -8970,9 +8970,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
"eslint@npm:10.8.1":
version: 10.8.1
resolution: "eslint@npm:10.8.1"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9011,7 +9011,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
checksum: 10/7174710e740f95bfa97d7501419d717c03cf7760c3eea4a91cd10fb804538bbd8cdc0849b26afa789a56a89ee838b727e4b9329e347376f96b8e524727b90ba3
languageName: node
linkType: hard
@@ -10024,7 +10024,7 @@ __metadata:
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.3"
"@rsdoctor/rspack-plugin": "npm:1.6.2"
"@rspack/core": "npm:2.1.10"
"@rspack/dev-server": "npm:2.2.0"
"@swc/helpers": "npm:0.5.23"
@@ -10070,7 +10070,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.0"
eslint: "npm:10.8.1"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"