Compare commits

..

2 Commits

Author SHA1 Message Date
Claude 69bae60ab2 Record fetched artifact only after successful extraction
A failure mid-fetch previously left a fresh artifact.json next to wiped
translations, so builds within the next 24 hours skipped the fetch and
silently built English only. Writing the artifact file last means a
failed fetch is retried by the next build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi
2026-07-24 13:29:37 +00:00
Claude 85378b2179 Fall back to English when nightly translations cannot be fetched
Local builds no longer fail when the nightly translations fetch errors
(offline or invalid GitHub credentials); they warn and continue with
English only, like the existing no-token path. CI still fails so
releases cannot silently ship without translations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi
2026-07-24 12:15:04 +00:00
39 changed files with 258 additions and 1765 deletions
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
currentArtifact = null;
}
try {
await fetchTranslations(currentArtifact);
} catch (err) {
// Local builds should work offline or without valid GitHub credentials,
// so fall back to English only. CI must fail instead of silently
// building without translations.
if (process.env.CI) {
throw err;
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err.message
);
}
});
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
writings.push(
createExtractDir.then(
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
)
);
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});
});
// Record the artifact only after successful extraction, so a failed fetch
// is retried by the next build instead of being considered current.
await createExtractDir;
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
}
gulp.task(
"setup-and-fetch-nightly-translations",
-4
View File
@@ -1,8 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -39,8 +37,6 @@ export const setDemoConfig = async (
selectedDemoConfigIndex = index;
selectedDemoConfig = confProm;
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
-4
View File
@@ -3,8 +3,6 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
import type { DemoArea } from "../stubs/area_registry";
import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
@@ -17,7 +15,5 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
}
+18 -23
View File
@@ -41,30 +41,25 @@ class CastDemoRow extends LitElement implements LovelaceRow {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
getCastManager().then(
(mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
}
getCastManager().then((mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
}
);
},
() => {
this._castManager = null;
}
)
}
);
})
);
}
+5 -15
View File
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -14,7 +14,7 @@ import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFloorRegistry } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
@@ -29,7 +29,6 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
@@ -75,17 +74,11 @@ export class HaDemo extends HomeAssistantAppEl {
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried. usage_prediction
// is needed for common-controls sections in strategy dashboards.
// mocked backup/config/info and webhook/list are queried.
hass.updateHass({
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
],
components: [...(hass.config?.components ?? []), "backup", "webhook"],
},
});
@@ -129,7 +122,6 @@ export class HaDemo extends HomeAssistantAppEl {
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -177,11 +169,9 @@ export class HaDemo extends HomeAssistantAppEl {
hass.addEntities(energyEntities());
// Once config is loaded AND localize, set registries, entities and theme.
// Once config is loaded AND localize, set entities and apply theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
}
+6 -38
View File
@@ -1,46 +1,14 @@
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoArea {
area_id: string;
name: string;
floor_id?: string;
icon?: string;
temperature_entity_id?: string;
humidity_entity_id?: string;
}
let areas: AreaRegistryEntry[] = [];
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
data: AreaRegistryEntry[] = []
) => {
hass.mockWS("config/area_registry/list", () => areas);
setDemoAreas(hass, data);
};
/** Set the areas of the currently loaded demo config. */
export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
hass.mockWS("config/area_registry/list", () => data);
const areas = {};
data.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas: areasById });
hass.updateHass({ areas });
};
+2 -35
View File
@@ -1,40 +1,7 @@
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoFloor {
floor_id: string;
name: string;
level?: number;
icon?: string;
}
let floors: FloorRegistryEntry[] = [];
export const mockFloorRegistry = (
hass: MockHomeAssistant,
data: DemoFloor[] = []
) => {
hass.mockWS("config/floor_registry/list", () => floors);
setDemoFloors(hass, data);
};
/** Set the floors of the currently loaded demo config. */
export const setDemoFloors = (
hass: MockHomeAssistant,
demoFloors: DemoFloor[] = []
) => {
floors = demoFloors.map((floor) => ({
floor_id: floor.floor_id,
name: floor.name,
level: floor.level ?? null,
icon: floor.icon ?? null,
aliases: [],
created_at: 0,
modified_at: 0,
}));
const floorsById: Record<string, FloorRegistryEntry> = {};
floors.forEach((floor) => {
floorsById[floor.floor_id] = floor;
});
hass.updateHass({ floors: floorsById });
};
data: FloorRegistryEntry[] = []
) => hass.mockWS("config/floor_registry/list", () => data);
-19
View File
@@ -1,19 +0,0 @@
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
// Entities that don't exist in the currently loaded demo config are
// filtered out by the common-controls section strategy.
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
entities: [
"light.living_room_floor_lamp",
"climate.living_room",
"cover.living_room_blinds",
"media_player.living_room_speaker",
"light.kitchen_spotlights",
"switch.coffee_machine",
"light.bedside_lamp",
"alarm_control_panel.home_alarm",
],
}));
};
+28 -5
View File
@@ -2,10 +2,7 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
@@ -13,6 +10,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
@@ -138,20 +136,45 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: DemoArea[] = [
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
+38 -10
View File
@@ -1,25 +1,21 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import {
mockFloorRegistry,
type DemoFloor,
} from "../../../../demo/src/stubs/floor_registry";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import {
showDialog,
@@ -151,43 +147,75 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: DemoArea[] = [
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: "ground",
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: "first",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: "ground",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
const FLOORS: DemoFloor[] = [
const FLOORS: FloorRegistryEntry[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
icon: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "first",
name: "First floor",
level: 1,
icon: "mdi:numeric-1",
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "second",
name: "Second floor",
level: 2,
icon: "mdi:numeric-2",
aliases: [],
created_at: 0,
modified_at: 0,
},
];
+6 -5
View File
@@ -108,7 +108,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.2",
"js-yaml": "5.2.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -152,7 +152,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/core": "2.1.4",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -196,7 +196,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.1.1",
"lint-staged": "17.1.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -207,7 +207,7 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
"tar": "7.5.21",
"tar": "7.5.20",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
@@ -226,7 +226,8 @@
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.17.1",
"volta": {
+10 -11
View File
@@ -9,17 +9,16 @@ export const castApiAvailable = () => {
loadedPromise = new Promise((resolve) => {
(window as any).__onGCastApiAvailable = resolve;
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
).catch(() => resolve(false));
});
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
);
return loadedPromise;
};
+2 -22
View File
@@ -24,8 +24,6 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
@@ -192,6 +190,8 @@ export class HaToast extends LitElement {
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -200,14 +200,6 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -262,18 +254,6 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
-10
View File
@@ -36,13 +36,3 @@ export interface DirtyStateContext<
* boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
declare global {
interface Window {
isDirtyState?: boolean;
}
interface HASSDomEvents {
"dirty-state-changed": { isDirty: boolean };
}
}
@@ -54,7 +54,6 @@ export class HaImagecropperDialog
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!changedProperties.has("_params") || !this._params) {
return;
}
-32
View File
@@ -16,38 +16,6 @@ export const demoPanels: Panels = {
config: null,
url_path: "home",
},
light: {
component_name: "light",
icon: "mdi:lamps",
title: "light",
default_visible: false,
config: null,
url_path: "light",
},
climate: {
component_name: "climate",
icon: "mdi:home-thermometer",
title: "climate",
default_visible: false,
config: null,
url_path: "climate",
},
security: {
component_name: "security",
icon: "mdi:security",
title: "security",
default_visible: false,
config: null,
url_path: "security",
},
maintenance: {
component_name: "maintenance",
icon: "mdi:wrench",
title: "maintenance",
default_visible: false,
config: null,
url_path: "maintenance",
},
"dev-state": {
component_name: "dev-state",
icon: null,
-3
View File
@@ -27,8 +27,6 @@ export class MockBaseEntity {
public state: string;
public areaId?: string;
public baseAttributes: EntityAttributes;
public attributes: EntityAttributes;
@@ -49,7 +47,6 @@ export class MockBaseEntity {
this.domain = domain;
this.objectId = objectId;
this.state = input.state;
this.areaId = input.area_id;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
+1 -4
View File
@@ -10,10 +10,7 @@ export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
export type EntityInput = Pick<
HassEntity,
"entity_id" | "state" | "attributes"
> & {
/** Area the entity is assigned to in the mocked entity registry */
area_id?: string;
};
>;
/**
* The hass mock object interface, kept intentionally loose
-1
View File
@@ -299,7 +299,6 @@ export const provideHass = (
icon: undefined,
platform: "demo",
labels: [],
area_id: ent.areaId,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
+26 -50
View File
@@ -13,13 +13,8 @@ export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
announceMessage?:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
action?: ToastActionParams;
secondaryAction?: ToastActionParams;
dismiss?: () => void;
duration?: number;
dismissable?: boolean;
@@ -28,10 +23,8 @@ export interface ShowToastParams {
export interface ToastActionParams {
action: () => void;
primary?: boolean;
text:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
}
@customElement("notification-manager")
@@ -100,22 +93,31 @@ class NotificationManager extends LitElement {
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.action
? html`
<ha-button
appearance="plain"
size="s"
slot="action"
@click=${this._buttonClicked}
>
${
typeof this._parameters?.action.text !== "string"
? this.hass.localize(
this._parameters?.action.text.translationKey,
this._parameters?.action.text.args
)
: this._parameters?.action.text
}
</ha-button>
`
: nothing
}
${
this._parameters?.dismissable
? html`
@@ -132,37 +134,11 @@ class NotificationManager extends LitElement {
`;
}
private _renderAction(
action: ToastActionParams | undefined,
secondary: boolean
) {
if (!action) {
return nothing;
}
return html`
<ha-button
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
>
${
typeof action.text !== "string"
? this.hass.localize(action.text.translationKey, action.text.args)
: action.text
}
</ha-button>
`;
}
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
if (this._parameters?.action) {
this._parameters?.action.action();
}
}
private _dismissClicked() {
+1 -35
View File
@@ -1,8 +1,7 @@
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { LitElement, PropertyValues } from "lit";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
@@ -18,18 +17,6 @@ export type CompareStrategy<State> =
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
const connectedDirtyStateProviders = new Map<object, boolean>();
const publishGlobalDirtyState = (): void => {
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
Boolean
);
if (isDirty !== window.isDirtyState) {
window.isDirtyState = isDirty;
fireEvent(window, "dirty-state-changed", { isDirty });
}
};
/**
* Mixin that provides dirty-state tracking via Lit context.
*
@@ -39,9 +26,6 @@ const publishGlobalDirtyState = (): void => {
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* Connected providers contribute to the global dirty state. It remains dirty
* while any provider has unsaved changes.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
@@ -134,24 +118,6 @@ export const DirtyStateProviderMixin =
this._dirtyStateContext = this._buildContextValue();
}
public connectedCallback(): void {
super.connectedCallback();
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
public disconnectedCallback(): void {
connectedDirtyStateProviders.delete(this);
publishGlobalDirtyState();
super.disconnectedCallback();
}
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
const slice = this._dirtySlices.get(key);
if (!slice) {
@@ -203,7 +203,6 @@ class DialogBackupOnboarding
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_step") && this._step === "key") {
this._save();
}
@@ -145,10 +145,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
public connectedCallback() {
super.connectedCallback();
// Re-apply the type filter from the URL when the page is (re)displayed,
// e.g. when navigating back to a cached instance of this page with a
// different `type` query param.
this._setFiltersFromUrl();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
@@ -146,7 +146,6 @@ export class HuiDialogEditBadge
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!changedProps.has("_badgeConfig")) {
return;
}
@@ -135,7 +135,6 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
return;
}
@@ -76,7 +76,6 @@ export class HuiDialogEditSection
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const sectionConfig = {
...this._config,
@@ -94,7 +94,6 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const viewConfig = {
...this._config,
@@ -43,7 +43,6 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -44,7 +44,6 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -107,7 +107,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
.value=${Number(stateObj.state).toString()}
.value=${stateObj.state}
type="number"
@change=${this._selectedValueChanged}
>
-1
View File
@@ -104,7 +104,6 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
if (
!this._saving &&
+2 -2
View File
@@ -2510,8 +2510,8 @@
"triggered": "Triggered {name}",
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
"update_now": "Update now",
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
"onboarding_survey": {
+16 -94
View File
@@ -1,8 +1,5 @@
import { showToast } from "./toast";
const UPDATE_DELAY = 60_000;
const UPDATE_TOAST_ID = "frontend-update-available";
export const supportsServiceWorker = () =>
"serviceWorker" in navigator &&
(location.protocol === "https:" || location.hostname === "localhost");
@@ -26,92 +23,6 @@ export const registerServiceWorker = async (
return;
}
let pendingWorker: ServiceWorker | undefined;
let updateDeadline = 0;
let updateInterval = 0;
const hideUpdateToast = () => {
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: "",
duration: 0,
});
};
const clearUpdateCountdown = () => {
clearInterval(updateInterval);
};
const activateUpdate = () => {
if (!pendingWorker) {
return;
}
clearUpdateCountdown();
hideUpdateToast();
pendingWorker.postMessage({ type: "skipWaiting" });
pendingWorker = undefined;
};
const showUpdateToast = () => {
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
if (seconds <= 0) {
activateUpdate();
return;
}
const announceSeconds =
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: announceSeconds },
},
action: {
action: activateUpdate,
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
action: () => {
pendingWorker = undefined;
clearUpdateCountdown();
},
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
};
const startUpdateCountdown = () => {
updateDeadline = Date.now() + UPDATE_DELAY;
showUpdateToast();
updateInterval = window.setInterval(showUpdateToast, 1000);
};
window.addEventListener("dirty-state-changed", () => {
if (!pendingWorker) {
return;
}
if (window.isDirtyState) {
clearUpdateCountdown();
hideUpdateToast();
return;
}
startUpdateCountdown();
});
const updateReady = (worker: ServiceWorker) => {
clearUpdateCountdown();
pendingWorker = worker;
if (!window.isDirtyState) {
startUpdateCountdown();
}
};
reg.addEventListener("updatefound", () => {
const installingWorker = reg.installing;
@@ -126,11 +37,22 @@ export const registerServiceWorker = async (
) {
return;
}
updateReady(installingWorker);
// Notify users a new frontend is available.
showToast(rootEl, {
message: {
translationKey: "ui.notification_toast.new_version_available",
},
action: {
// We tell the service worker to call skipWaiting, which activates
// the new service worker. Above we listen for `controllerchange`
// so we reload the page once a new service worker activates.
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
text: { translationKey: "ui.notification_toast.reload" },
},
duration: -1,
dismissable: false,
});
});
});
if (reg.waiting && navigator.serviceWorker.controller) {
updateReady(reg.waiting);
}
};
-177
View File
@@ -1,177 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaToast } from "../../src/components/ha-toast";
import "../../src/components/ha-toast";
let toast: HaToast | undefined;
const mountToast = async (properties: Partial<HaToast> = {}) => {
toast = document.createElement("ha-toast");
Object.assign(toast, properties);
document.body.append(toast);
await toast.updateComplete;
return toast;
};
afterEach(() => {
toast?.remove();
toast = undefined;
vi.useRealTimers();
});
const showToast = async (element: HaToast) => {
vi.useFakeTimers();
const shown = element.show();
await element.updateComplete;
Object.defineProperty(
element.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await shown;
};
describe("ha-toast", () => {
it("renders its message and bottom offset", async () => {
const element = await mountToast({
labelText: "Configuration saved",
bottomOffset: 24,
});
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
"Configuration saved"
);
expect(
(
element.shadowRoot?.querySelector(".toast") as HTMLElement
).style.getPropertyValue("--ha-toast-bottom-offset")
).toBe("24px");
});
it("renders assigned action and dismiss content", async () => {
toast = document.createElement("ha-toast");
const action = document.createElement("button");
action.slot = "action";
const dismiss = document.createElement("button");
dismiss.slot = "dismiss";
toast.append(action, dismiss);
document.body.append(toast);
await toast.updateComplete;
toast.requestUpdate();
await toast.updateComplete;
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="action"]')
?.assignedElements()
).toEqual([action]);
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
?.assignedElements()
).toEqual([dismiss]);
expect(
toast.shadowRoot
?.querySelector(".actions")
?.classList.contains("has-action")
).toBe(true);
});
it("keeps changing visual text separate from live-region text", async () => {
const element = await mountToast({
labelText: "Updating in 59 seconds",
announceText: "Updating in 60 seconds",
});
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
expect(visibleMessage.closest('[role="status"]')).toBeNull();
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
expect(liveRegion.getAttribute("role")).toBe("status");
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
});
it("falls back to announcing the visible message", async () => {
const element = await mountToast({ labelText: "Configuration saved" });
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.textContent?.trim()
).toBe("Configuration saved");
});
it("activates its live region while shown", async () => {
const element = await mountToast();
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(liveRegion.getAttribute("aria-live")).toBe("off");
await showToast(element);
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await element.hide(reason);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
expect(
element
.shadowRoot!.querySelector(".toast")!
.classList.contains("active")
).toBe(false);
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.getAttribute("aria-live")
).toBe("off");
}
);
it("closes with a timeout reason", async () => {
const element = await mountToast({ timeoutMs: 1000 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(1000);
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
});
it("does not close automatically when the timeout is disabled", async () => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(10_000);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit a close event when already hidden", async () => {
const element = await mountToast();
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await element.hide("dismiss");
expect(listener).not.toHaveBeenCalled();
});
});
@@ -1,119 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
const localize = (key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key;
@customElement("test-notification-lifecycle-host")
class TestNotificationLifecycleHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
}
}
let host: TestNotificationLifecycleHost | undefined;
afterEach(() => {
host?.remove();
host = undefined;
vi.useRealTimers();
});
describe("notification toast lifecycle", () => {
it("updates a visible toast and closes it after its primary action", async () => {
vi.useFakeTimers();
host = document.createElement("test-notification-lifecycle-host");
document.body.append(host);
await host.updateComplete;
const manager = host.shadowRoot!.querySelector("notification-manager")!;
const action = vi.fn();
const firstShow = manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
await Promise.resolve();
await manager.updateComplete;
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
Object.defineProperty(
toast.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await firstShow;
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
"visible"
);
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":60'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
await manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":59'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
const closed = new Promise<void>((resolve) => {
toast.addEventListener("toast-closed", () => resolve(), { once: true });
});
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
await closed;
await manager.updateComplete;
expect(action).toHaveBeenCalledOnce();
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
-245
View File
@@ -1,245 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key
);
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-toast", () => {
customElements.define(
"ha-toast",
class extends HTMLElement {
public show = vi.fn();
public hide = vi.fn();
}
);
return {};
});
@customElement("test-notification-manager-host")
class TestNotificationManagerHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-manager-host": TestNotificationManagerHost;
}
}
let host: TestNotificationManagerHost | undefined;
const mountManager = async () => {
host = document.createElement(
"test-notification-manager-host"
) as TestNotificationManagerHost;
document.body.append(host);
await host.updateComplete;
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
{ duration: 1, expected: 4000 },
{ duration: 4000, expected: 4000 },
{ duration: 4001, expected: 4001 },
{ duration: -1, expected: -1 },
])(
"normalizes a $duration ms duration to $expected ms",
async ({ duration, expected }) => {
const manager = await mountManager();
await manager.showDialog({ message: "Message", duration });
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
expected
);
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
await manager.showDialog({
message: "Update available",
action: { action: primary, primary: true, text: "Update now" },
secondaryAction: { action: secondary, text: "Cancel" },
duration: -1,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(secondary).toHaveBeenCalledOnce();
expect(primary).not.toHaveBeenCalled();
buttons[1].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(primary).toHaveBeenCalledOnce();
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "status", message: "Second" });
expect(toast.hide).not.toHaveBeenCalled();
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Message" });
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
new CustomEvent("toast-closed", {
detail: { reason: "programmatic" },
})
);
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
@@ -1,368 +0,0 @@
import { consume } from "@lit/context";
import { LitElement } from "lit";
import { state } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../src/data/context/dirty-state";
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
interface TestState {
name: string;
enabled: boolean;
options?: { mode: string };
}
class TestDirtyStateConsumer extends LitElement {
@consume({ context: dirtyStateContext, subscribe: true })
@state()
public dirtyState?: DirtyStateContext;
}
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
LitElement
) {
public initialize(
strategy: CompareStrategy<TestState>,
value?: TestState,
normalize?: (value: TestState) => TestState
) {
this._initDirtyTracking(strategy, value, normalize);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
public discardChanges() {
this._discardDirtyStateChanges();
}
}
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
declare global {
interface HTMLElementTagNameMap {
"test-dirty-state-consumer": TestDirtyStateConsumer;
"test-dirty-state-provider": TestDirtyStateProvider;
}
}
let provider: TestDirtyStateProvider | undefined;
const mountProvider = async () => {
provider = document.createElement(
"test-dirty-state-provider"
) as TestDirtyStateProvider;
document.body.append(provider);
await provider.updateComplete;
return provider;
};
afterEach(() => {
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
element.remove();
});
provider = undefined;
window.isDirtyState = false;
});
describe("DirtyStateProviderMixin", () => {
it("tracks changes against an initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("marks the current state as clean", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
element.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("discards changes back to the initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: true });
await element.updateComplete;
element.discardChanges();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("uses deep comparison for nested values", async () => {
const element = await mountProvider();
element.initialize(
{ type: "deep" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "manual" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("uses reference comparison for nested values with a shallow strategy", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("supports a custom comparison strategy", async () => {
const element = await mountProvider();
const compare = vi.fn(
(initial: TestState, current: TestState) =>
initial.name.toLowerCase() === current.name.toLowerCase()
);
element.initialize(
{ type: "custom", compare },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "KITCHEN", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(compare).toHaveBeenCalled();
});
it("keeps the deep baseline isolated from initial state mutations", async () => {
const element = await mountProvider();
const initial = {
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
};
element.initialize({ type: "deep" }, initial);
initial.options.mode = "manual";
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("uses the first deferred value as its baseline", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("tracks raw and effective dirty state separately", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false },
(value) => ({ ...value, enabled: false })
);
element.setValue({ name: "Kitchen", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("publishes dirty and clean transitions with event details", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
listener.mockClear();
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(listener).toHaveBeenCalledTimes(2);
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
expect(window.isDirtyState).toBe(false);
window.removeEventListener("dirty-state-changed", listener);
});
it("clears published dirty state when disconnected", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
listener.mockClear();
element.remove();
expect(window.isDirtyState).toBe(false);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
window.removeEventListener("dirty-state-changed", listener);
});
it("hands global dirty state to the next provider after disconnecting", async () => {
const firstProvider = await mountProvider();
firstProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
firstProvider.setValue({ name: "Bedroom", enabled: false });
await firstProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
firstProvider.remove();
expect(window.isDirtyState).toBe(false);
const nextProvider = await mountProvider();
nextProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
nextProvider.setValue({ name: "Hallway", enabled: true });
await nextProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
});
it("stays globally dirty while any connected provider is dirty", async () => {
const dirtyProvider = await mountProvider();
dirtyProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
await dirtyProvider.updateComplete;
const cleanProvider = document.createElement("test-dirty-state-provider");
document.body.append(cleanProvider);
cleanProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
await cleanProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
cleanProvider.remove();
expect(window.isDirtyState).toBe(true);
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
await dirtyProvider.updateComplete;
expect(window.isDirtyState).toBe(false);
});
it("restores a provider's dirty state after reconnecting", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.remove();
expect(window.isDirtyState).toBe(false);
document.body.append(element);
expect(window.isDirtyState).toBe(true);
});
it("tracks independent context slices and marks all of them clean", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
const consumer = document.createElement("test-dirty-state-consumer");
element.append(consumer);
await consumer.updateComplete;
const setState = (value: TestState, key: "first" | "second") => {
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
};
setState({ name: "Kitchen", enabled: false }, "first");
setState({ name: "Bedroom", enabled: false }, "second");
await element.updateComplete;
setState({ name: "Kitchen light", enabled: false }, "first");
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
consumer.dirtyState!.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
});
-341
View File
@@ -1,341 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ShowToastParams } from "../../src/managers/notification-manager";
import {
registerServiceWorker,
supportsServiceWorker,
} from "../../src/util/register-service-worker";
class FakeWorker extends EventTarget {
public state: ServiceWorkerState = "installing";
public postMessage = vi.fn();
}
class FakeRegistration extends EventTarget {
public installing: ServiceWorker | null = null;
public waiting: ServiceWorker | null = null;
}
class FakeServiceWorkerContainer extends EventTarget {
public controller: ServiceWorker | null = null;
public register = vi.fn();
}
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"serviceWorker"
);
const restoreServiceWorker = () => {
if (serviceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
} else {
Reflect.deleteProperty(navigator, "serviceWorker");
}
};
describe("supportsServiceWorker", () => {
afterEach(restoreServiceWorker);
it("returns true when the service worker API is available", () => {
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: new FakeServiceWorkerContainer(),
});
expect(supportsServiceWorker()).toBe(true);
});
it("returns false when the service worker API is unavailable", () => {
Reflect.deleteProperty(navigator, "serviceWorker");
expect(supportsServiceWorker()).toBe(false);
});
});
describe("registerServiceWorker", () => {
let root: HTMLElement;
let worker: FakeWorker;
let registration: FakeRegistration;
let serviceWorker: FakeServiceWorkerContainer;
let notifications: ShowToastParams[];
const latestNotification = () => notifications[notifications.length - 1];
const setDirtyState = (isDirty: boolean) => {
window.isDirtyState = isDirty;
window.dispatchEvent(
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
);
};
const installUpdate = () => {
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "installed";
worker.dispatchEvent(new Event("statechange"));
};
beforeEach(() => {
vi.useFakeTimers();
globalThis.__BUILD__ = "modern";
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
window.isDirtyState = false;
notifications = [];
root = document.createElement("div");
root.addEventListener("hass-notification", (event) => {
notifications.push((event as CustomEvent<ShowToastParams>).detail);
});
worker = new FakeWorker();
registration = new FakeRegistration();
serviceWorker = new FakeServiceWorkerContainer();
serviceWorker.controller = worker as unknown as ServiceWorker;
serviceWorker.register.mockResolvedValue(registration);
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
});
afterEach(() => {
latestNotification()?.secondaryAction?.action();
vi.clearAllTimers();
vi.useRealTimers();
window.isDirtyState = false;
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
restoreServiceWorker();
});
it("registers the worker for the current build", async () => {
await registerServiceWorker(root, false);
expect(serviceWorker.register).toHaveBeenCalledOnce();
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
});
it("does not register when service workers are unsupported", async () => {
Reflect.deleteProperty(navigator, "serviceWorker");
await registerServiceWorker(root);
expect(serviceWorker.register).not.toHaveBeenCalled();
});
it("listens for controller changes before registering", async () => {
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
await registerServiceWorker(root, false);
expect(addEventListener).toHaveBeenCalledWith(
"controllerchange",
expect.any(Function)
);
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
serviceWorker.register.mock.invocationCallOrder[0]
);
});
it.each([
{ notifyUpdate: false, dev: false, demo: false },
{ notifyUpdate: true, dev: true, demo: false },
{ notifyUpdate: true, dev: false, demo: true },
])(
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
async ({ notifyUpdate, dev, demo }) => {
globalThis.__DEV__ = dev;
globalThis.__DEMO__ = demo;
const addEventListener = vi.spyOn(registration, "addEventListener");
await registerServiceWorker(root, notifyUpdate);
expect(addEventListener).not.toHaveBeenCalledWith(
"updatefound",
expect.any(Function)
);
}
);
it("ignores update discovery without an installing worker", async () => {
await registerServiceWorker(root);
registration.dispatchEvent(new Event("updatefound"));
expect(notifications).toHaveLength(0);
});
it("waits for an installing worker to reach the installed state", async () => {
await registerServiceWorker(root);
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "activating";
worker.dispatchEvent(new Event("statechange"));
expect(notifications).toHaveLength(0);
});
it("ignores an initial installation without an existing controller", async () => {
serviceWorker.controller = null;
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
});
it("starts at 60 seconds when an update is installed", async () => {
await registerServiceWorker(root);
installUpdate();
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
args: { seconds: 60 },
},
action: {
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
});
it("uses an already waiting worker", async () => {
registration.waiting = worker as unknown as ServiceWorker;
await registerServiceWorker(root);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("ignores a waiting worker without an existing controller", async () => {
registration.waiting = worker as unknown as ServiceWorker;
serviceWorker.controller = null;
await registerServiceWorker(root);
expect(notifications).toHaveLength(0);
});
it.each([
{ elapsed: 1_000, visual: 59, announced: 60 },
{ elapsed: 20_000, visual: 40, announced: 40 },
{ elapsed: 40_000, visual: 20, announced: 20 },
{ elapsed: 55_000, visual: 5, announced: 5 },
{ elapsed: 59_000, visual: 1, announced: 5 },
])(
"shows $visual seconds and announces $announced after $elapsed ms",
async ({ elapsed, visual, announced }) => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(elapsed);
expect(latestNotification().message).toMatchObject({
args: { seconds: visual },
});
expect(latestNotification().announceMessage).toMatchObject({
args: { seconds: announced },
});
}
);
it("activates and hides the update when the countdown finishes", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: "",
duration: 0,
});
});
it("activates and hides the update immediately on request", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().action!.action();
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
});
it("cancels automatic activation", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().secondaryAction!.action();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
});
it("defers the message and activation while the page is dirty", async () => {
setDirtyState(true);
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("hides and resets the countdown when the page becomes dirty", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(10_000);
setDirtyState(true);
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("uses the latest installed worker", async () => {
await registerServiceWorker(root);
installUpdate();
const replacement = new FakeWorker();
replacement.state = "installed";
registration.installing = replacement as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
replacement.dispatchEvent(new Event("statechange"));
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
expect(replacement.postMessage).toHaveBeenCalledWith({
type: "skipWaiting",
});
});
});
+73 -73
View File
@@ -4755,65 +4755,65 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-darwin-arm64@npm:2.1.5"
"@rspack/binding-darwin-arm64@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-darwin-arm64@npm:2.1.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-darwin-x64@npm:2.1.5"
"@rspack/binding-darwin-x64@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-darwin-x64@npm:2.1.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.5"
"@rspack/binding-linux-arm64-gnu@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.4"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.5"
"@rspack/binding-linux-arm64-musl@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.4"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.5"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.4"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.5"
"@rspack/binding-linux-riscv64-musl@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.4"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.5"
"@rspack/binding-linux-x64-gnu@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.4"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.5"
"@rspack/binding-linux-x64-musl@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.4"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.5"
"@rspack/binding-wasm32-wasi@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.4"
dependencies:
"@emnapi/core": "npm:1.11.2"
"@emnapi/runtime": "npm:1.11.2"
@@ -4822,43 +4822,43 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.5"
"@rspack/binding-win32-arm64-msvc@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.5"
"@rspack/binding-win32-ia32-msvc@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.4"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.5"
"@rspack/binding-win32-x64-msvc@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/binding@npm:2.1.5"
"@rspack/binding@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/binding@npm:2.1.4"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.5"
"@rspack/binding-darwin-x64": "npm:2.1.5"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.5"
"@rspack/binding-linux-arm64-musl": "npm:2.1.5"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.5"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.5"
"@rspack/binding-linux-x64-gnu": "npm:2.1.5"
"@rspack/binding-linux-x64-musl": "npm:2.1.5"
"@rspack/binding-wasm32-wasi": "npm:2.1.5"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.5"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.5"
"@rspack/binding-win32-x64-msvc": "npm:2.1.5"
"@rspack/binding-darwin-arm64": "npm:2.1.4"
"@rspack/binding-darwin-x64": "npm:2.1.4"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.4"
"@rspack/binding-linux-arm64-musl": "npm:2.1.4"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.4"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.4"
"@rspack/binding-linux-x64-gnu": "npm:2.1.4"
"@rspack/binding-linux-x64-musl": "npm:2.1.4"
"@rspack/binding-wasm32-wasi": "npm:2.1.4"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.4"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.4"
"@rspack/binding-win32-x64-msvc": "npm:2.1.4"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4884,15 +4884,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/d1054348d3ba734485f977d574c6311311707adfcc5e53f03c8dbbbdc5778cff9b815e1d675992e2832241606d4cce7678acc3999b259139120b4ec30751f1e1
checksum: 10/425bf152dba708992ce16114ce6bc8dfa424071694e85201317518dce1899eeae873a2adbd23b214bb8d223f7290e000fd78ad9bf262c633e12d3d4ff73bbdcd
languageName: node
linkType: hard
"@rspack/core@npm:2.1.5":
version: 2.1.5
resolution: "@rspack/core@npm:2.1.5"
"@rspack/core@npm:2.1.4":
version: 2.1.4
resolution: "@rspack/core@npm:2.1.4"
dependencies:
"@rspack/binding": "npm:2.1.5"
"@rspack/binding": "npm:2.1.4"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4901,7 +4901,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/714064de701211724f7d859bc269d357fa2ad2f7ea0ac34d1e4e48b534981cf3961bd723c0659b9c093330f04e01e16a0c5587d6a18301727e27d717f764ded7
checksum: 10/3c7aa9e8dbe8b132b51fc017a6c2c76ddf346cf663cad2a8912ba61469d80468acd045503d405bf962bb5ec44f44036512dd6d6233ea6d1419979e5c17e1dbd8
languageName: node
linkType: hard
@@ -9955,7 +9955,7 @@ __metadata:
"@playwright/test": "npm:1.61.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.5"
"@rspack/core": "npm:2.1.4"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -10023,14 +10023,14 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.12"
js-yaml: "npm:5.2.2"
js-yaml: "npm:5.2.1"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.1.1"
lint-staged: "npm:17.1.0"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -10056,7 +10056,7 @@ __metadata:
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.21"
tar: "npm:7.5.20"
terser-webpack-plugin: "npm:5.6.1"
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
@@ -10957,14 +10957,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.2.2":
version: 5.2.2
resolution: "js-yaml@npm:5.2.2"
"js-yaml@npm:5.2.1":
version: 5.2.1
resolution: "js-yaml@npm:5.2.1"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
checksum: 10/e1eca2d21c15572585bb236d9fde31d6789eb50b9c63e8753fa7e0777bc480f7521cad517bd7a0c66f27dfc27ddcd7100beeefa51c1a50e10e98f2e009633c3d
languageName: node
linkType: hard
@@ -11423,9 +11423,9 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.1.1":
version: 17.1.1
resolution: "lint-staged@npm:17.1.1"
"lint-staged@npm:17.1.0":
version: 17.1.0
resolution: "lint-staged@npm:17.1.0"
dependencies:
picomatch: "npm:^4.0.5"
string-argv: "npm:^0.3.2"
@@ -11436,7 +11436,7 @@ __metadata:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/a54e063a579c093cecd4fc210f78c424fa0aa1dbe8196cf57160fad05acbfe4cb60ddd50751c799277094a0b7949de0ea3b61392b90e137f5c5bdf8473826e72
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
languageName: node
linkType: hard
@@ -14501,16 +14501,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.21
resolution: "tar@npm:7.5.21"
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.20
resolution: "tar@npm:7.5.20"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
languageName: node
linkType: hard