mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-11 11:27:51 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27148f68c |
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -60,7 +60,7 @@ const generateSumStatistics = (
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
@@ -69,9 +69,7 @@ const generateSumStatistics = (
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const bound =
|
||||
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
|
||||
const add = Math.random() * bound;
|
||||
const add = Math.random() * maxDiff;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
@@ -138,15 +136,6 @@ const statisticsFunctions: Record<
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
) => StatisticValue[]
|
||||
> = {
|
||||
"sensor.energy_water": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
4000,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -312,11 +301,6 @@ const statisticsFunctions: Record<
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
};
|
||||
// Prices are per source unit; water uses litres (4 currency units per m³).
|
||||
const derivedCosts: Record<string, { source: string; price: number }> = {
|
||||
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
|
||||
};
|
||||
|
||||
export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/get_statistics_metadata",
|
||||
@@ -333,23 +317,10 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
const getStats = (id: string) => {
|
||||
cache[id] ??= statisticsFunctions[id](id, start, end, period);
|
||||
return cache[id];
|
||||
};
|
||||
|
||||
statistic_ids.forEach((id: string) => {
|
||||
if (id in derivedCosts) {
|
||||
const { source, price } = derivedCosts[id];
|
||||
let cost = 0;
|
||||
statistics[id] = getStats(source).map((statistic) => {
|
||||
const change = statistic.change! * price;
|
||||
cost += change;
|
||||
return { ...statistic, change, state: cost, sum: cost };
|
||||
});
|
||||
} else if (id in statisticsFunctions) {
|
||||
statistics[id] = getStats(id);
|
||||
if (id in statisticsFunctions) {
|
||||
statistics[id] = statisticsFunctions[id](id, start, end, period);
|
||||
} else {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
|
||||
+11
-11
@@ -40,17 +40,17 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.1",
|
||||
@@ -85,7 +85,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "6.3.1",
|
||||
"@versatiles/style": "6.2.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -94,7 +94,7 @@
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.7.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.13.0",
|
||||
"maplibre-gl": "6.12.0",
|
||||
"marked": "18.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
@@ -141,11 +141,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/core": "8.0.6",
|
||||
"@babel/helper-compilation-targets": "8.0.6",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@babel/preset-env": "8.0.6",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
@@ -153,7 +153,7 @@
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
@@ -225,7 +225,7 @@
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected].1",
|
||||
"packageManager": "[email protected].0",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
}
|
||||
|
||||
Generated
+474
-492
File diff suppressed because it is too large
Load Diff
@@ -362,17 +362,11 @@ export type ConditionWithShorthand =
|
||||
| ShorthandAndConditionList
|
||||
| ShorthandAndCondition
|
||||
| ShorthandOrCondition
|
||||
| ShorthandNotCondition
|
||||
| string;
|
||||
| ShorthandNotCondition;
|
||||
|
||||
export const expandConditionWithShorthand = (
|
||||
cond: ConditionWithShorthand
|
||||
): Condition => {
|
||||
// A bare template string is shorthand for a template condition
|
||||
if (typeof cond === "string") {
|
||||
return { condition: "template", value_template: cond };
|
||||
}
|
||||
|
||||
if ("condition" in cond && Array.isArray(cond.condition)) {
|
||||
return {
|
||||
condition: "and",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||
import { registerRoute, setCatchHandler } from "workbox-routing";
|
||||
import {
|
||||
CacheFirst,
|
||||
NetworkFirst,
|
||||
NetworkOnly,
|
||||
StaleWhileRevalidate,
|
||||
} from "workbox-strategies";
|
||||
@@ -19,6 +20,8 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
|
||||
const noFallBackRegEx =
|
||||
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
|
||||
|
||||
const NAVIGATION_TIMEOUT_SECONDS = 3;
|
||||
|
||||
// Camera / image proxy endpoints that carry credentials in the URL.
|
||||
// We pre-validate the credential in the service worker so obviously invalid
|
||||
// requests (signature expired, token missing) never reach the server and
|
||||
@@ -87,6 +90,29 @@ const ignoreTokenPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
// A proxy answers 5xx while core restarts; the cached shell reconnects on its
|
||||
// own. Runs on the final response, so it also covers preloaded navigations.
|
||||
const serverErrorFallbackPlugin = {
|
||||
handlerWillRespond: async ({
|
||||
request,
|
||||
response,
|
||||
}: {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}) => {
|
||||
if (response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
const options = { cacheName: cacheNames.runtime, ignoreSearch: true };
|
||||
const cached =
|
||||
(await caches.match(request, options)) ??
|
||||
(noFallBackRegEx.test(request.url)
|
||||
? undefined
|
||||
: await caches.match("/", options));
|
||||
return cached ?? response;
|
||||
},
|
||||
};
|
||||
|
||||
const initRouting = () => {
|
||||
precacheAndRoute(__WB_MANIFEST__, {
|
||||
// Ignore all URL parameters.
|
||||
@@ -193,10 +219,16 @@ const initRouting = () => {
|
||||
// Get manifest and onboarding from network.
|
||||
registerRoute(/\/(?:manifest\.json|onboarding\.html)/, new NetworkOnly());
|
||||
|
||||
// For the root "/" we ignore search
|
||||
// Every navigation gets the same index.html, which imports this build's
|
||||
// hashed bundles. A stale cached copy imports deleted files, so the cache
|
||||
// is only the offline fallback.
|
||||
registerRoute(
|
||||
/\/(\?.*)?$/,
|
||||
new StaleWhileRevalidate({ matchOptions: { ignoreSearch: true } })
|
||||
({ request }) => request.mode === "navigate",
|
||||
new NetworkFirst({
|
||||
networkTimeoutSeconds: NAVIGATION_TIMEOUT_SECONDS,
|
||||
matchOptions: { ignoreSearch: true },
|
||||
plugins: [serverErrorFallbackPlugin],
|
||||
})
|
||||
);
|
||||
|
||||
// For rest of the files (on Home Assistant domain only) try both cache and network.
|
||||
@@ -339,25 +371,27 @@ const catchHandler: RouteHandler = async (options) => {
|
||||
};
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Delete all runtime caching, so that index.html has to be refetched.
|
||||
// And add the new index.html back to the runtime cache
|
||||
const cacheName = cacheNames.runtime;
|
||||
// Cached indexes import the previous build's bundles.
|
||||
event.waitUntil(
|
||||
caches.delete(cacheName).then(() =>
|
||||
caches.open(cacheName).then((cache) => {
|
||||
cache.add("/");
|
||||
})
|
||||
)
|
||||
(async () => {
|
||||
await caches.delete(cacheNames.runtime);
|
||||
try {
|
||||
const cache = await caches.open(cacheNames.runtime);
|
||||
await cache.add("/");
|
||||
} catch (_err) {
|
||||
// Offline or restarting; the first navigation fills it.
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", () => {
|
||||
// Attach the service worker to any page of the app
|
||||
// that didn't have a service worker loaded.
|
||||
// Happens the first time they open the app without any
|
||||
// service worker registered.
|
||||
// This will serve code split bundles from SW.
|
||||
clients.claim();
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
self.registration.navigationPreload?.enable(),
|
||||
clients.claim(),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (message) => {
|
||||
|
||||
@@ -8,13 +8,11 @@ import { showMatterAddDeviceDialog } from "./show-dialog-add-matter-device";
|
||||
export class MatterAddDevice extends HTMLElement {
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
async connectedCallback() {
|
||||
// Navigation closes open dialogs, so wait for it before showing the
|
||||
// dialog. This element is detached by then, open it from the app root.
|
||||
await navigate("/config/devices/dashboard", {
|
||||
connectedCallback() {
|
||||
navigate("/config/devices/dashboard", {
|
||||
replace: true,
|
||||
});
|
||||
showMatterAddDeviceDialog(document.querySelector("home-assistant") ?? this);
|
||||
showMatterAddDeviceDialog(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import "../../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
import { showMatterAddDeviceDialog } from "./show-dialog-add-matter-device";
|
||||
|
||||
const THREAD_ICON =
|
||||
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z";
|
||||
@@ -98,7 +97,7 @@ export class MatterConfigDashboard extends LitElement {
|
||||
${this._renderNavigationCard()}
|
||||
</div>
|
||||
|
||||
<ha-button slot="fab" size="l" @click=${this._addDevice}>
|
||||
<ha-button slot="fab" href="/config/matter/add" size="l">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.matter.panel.add_device")}
|
||||
</ha-button>
|
||||
@@ -106,10 +105,6 @@ export class MatterConfigDashboard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
showMatterAddDeviceDialog(this);
|
||||
}
|
||||
|
||||
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
|
||||
return html`
|
||||
<ha-card class="content network-status">
|
||||
|
||||
@@ -190,18 +190,16 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _unsubscribe(): Promise<void> {
|
||||
private _unsubscribe(): void {
|
||||
this._active = false;
|
||||
if (this._addDevicesTimeoutHandle) {
|
||||
clearTimeout(this._addDevicesTimeoutHandle);
|
||||
}
|
||||
let unsubscribed = Promise.resolve();
|
||||
if (this._subscribed) {
|
||||
unsubscribed = this._subscribed.then((unsub) => unsub());
|
||||
this._subscribed.then((unsub) => unsub());
|
||||
this._subscribed = undefined;
|
||||
}
|
||||
this._wakeLock?.then((wakeLock) => wakeLock.release());
|
||||
return unsubscribed;
|
||||
}
|
||||
|
||||
private _deactivate(): void {
|
||||
@@ -216,25 +214,15 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
// "Search again" lands here with the previous subscription still open.
|
||||
// Only subscribe once it is closed, so the backend ends that debug logging
|
||||
// session before starting the next one
|
||||
const unsubscribed = this._unsubscribe();
|
||||
this._active = true;
|
||||
const data: any = { type: "zha/devices/permit", duration: 254 };
|
||||
if (this._ieeeAddress) {
|
||||
data.ieee = this._ieeeAddress;
|
||||
}
|
||||
const connection = this.hass.connection;
|
||||
this._subscribed = unsubscribed
|
||||
// A failed unsubscribe leaves nothing to wait for
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
connection.subscribeMessage(
|
||||
(message) => this._handleMessage(message),
|
||||
data
|
||||
)
|
||||
);
|
||||
this._subscribed = this.hass.connection.subscribeMessage(
|
||||
(message) => this._handleMessage(message),
|
||||
data
|
||||
);
|
||||
this._addDevicesTimeoutHandle = setTimeout(
|
||||
() => this._deactivate(),
|
||||
254000
|
||||
|
||||
@@ -68,7 +68,7 @@ const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
|
||||
|
||||
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
|
||||
|
||||
const TIMESTAMP_DOMAIN_CONTENTS: Record<string, string[] | undefined> = {
|
||||
const TIMESTAMP_DOMAIN_CONTENTS = {
|
||||
calendar: ["start_time", "end_time"],
|
||||
input_datetime: ["timestamp"],
|
||||
sun: [
|
||||
@@ -106,11 +106,10 @@ export const stateContentHasTimestamp = (
|
||||
}
|
||||
}
|
||||
}
|
||||
const domainContents = TIMESTAMP_DOMAIN_CONTENTS[domain];
|
||||
return (
|
||||
!!content &&
|
||||
!!domainContents &&
|
||||
contentArray.some((c) => domainContents.includes(c))
|
||||
TIMESTAMP_DOMAIN_CONTENTS[domain] &&
|
||||
content &&
|
||||
contentArray.some((c) => TIMESTAMP_DOMAIN_CONTENTS[domain].includes(c))
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,29 @@ export const supportsServiceWorker = () =>
|
||||
"serviceWorker" in navigator &&
|
||||
(location.protocol === "https:" || location.hostname === "localhost");
|
||||
|
||||
/**
|
||||
* Reload once when a new worker takes over. The claim after a first install
|
||||
* is skipped: that page already runs the build it registered.
|
||||
*/
|
||||
export const reloadOnControllerChange = (
|
||||
container: ServiceWorkerContainer,
|
||||
reload: () => void = () => location.reload()
|
||||
) => {
|
||||
let claimed = Boolean(container.controller);
|
||||
let reloading = false;
|
||||
container.addEventListener("controllerchange", () => {
|
||||
if (!claimed) {
|
||||
claimed = true;
|
||||
return;
|
||||
}
|
||||
if (reloading) {
|
||||
return;
|
||||
}
|
||||
reloading = true;
|
||||
reload();
|
||||
});
|
||||
};
|
||||
|
||||
export const registerServiceWorker = async (
|
||||
rootEl: HTMLElement,
|
||||
notifyUpdate = true
|
||||
@@ -15,10 +38,7 @@ export const registerServiceWorker = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// If the active service worker changes, refresh the page because the cache has changed
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
location.reload();
|
||||
});
|
||||
reloadOnControllerChange(navigator.serviceWorker);
|
||||
|
||||
const reg = await navigator.serviceWorker.register(`/sw-${__BUILD__}.js`);
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandConditionWithShorthand } from "../../src/data/automation";
|
||||
|
||||
describe("expandConditionWithShorthand", () => {
|
||||
it("expands a template string to a template condition", () => {
|
||||
expect(
|
||||
expandConditionWithShorthand("{{ is_state('sun.sun', 'below_horizon') }}")
|
||||
).toEqual({
|
||||
condition: "template",
|
||||
value_template: "{{ is_state('sun.sun', 'below_horizon') }}",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands a condition list to an and condition", () => {
|
||||
const conditions = [{ condition: "state", entity_id: "light.kitchen" }];
|
||||
expect(
|
||||
expandConditionWithShorthand({ condition: conditions } as any)
|
||||
).toEqual({ condition: "and", conditions });
|
||||
});
|
||||
|
||||
it("expands building block shorthand", () => {
|
||||
const conditions = [{ condition: "state", entity_id: "light.kitchen" }];
|
||||
expect(expandConditionWithShorthand({ or: conditions } as any)).toEqual({
|
||||
condition: "or",
|
||||
conditions,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a regular condition unchanged", () => {
|
||||
const condition = { condition: "state", entity_id: "light.kitchen" } as any;
|
||||
expect(expandConditionWithShorthand(condition)).toBe(condition);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockRecorder } from "../../demo/src/stubs/recorder";
|
||||
import type { Statistics } from "../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
|
||||
const fetchWater = (
|
||||
period: "5minute" | "hour" | "day" | "month",
|
||||
ids = ["sensor.energy_water", "sensor.energy_water_cost"],
|
||||
start = "2026-01-01T00:00:00Z",
|
||||
end = "2026-02-01T00:00:00Z"
|
||||
): Statistics => {
|
||||
const mockWS = vi.fn();
|
||||
const hass = { mockWS, states: {} } as unknown as MockHomeAssistant;
|
||||
mockRecorder(hass);
|
||||
const handler = mockWS.mock.calls.find(
|
||||
([type]) => type === "recorder/statistics_during_period"
|
||||
)![1];
|
||||
return handler(
|
||||
{
|
||||
statistic_ids: ids,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
period,
|
||||
},
|
||||
hass
|
||||
);
|
||||
};
|
||||
|
||||
describe("demo water statistics", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["5minute", 20 / 24],
|
||||
["hour", 10],
|
||||
["day", 240],
|
||||
["month", 7440],
|
||||
] as const)("scales consumption for %s buckets", (period, expected) => {
|
||||
expect(fetchWater(period)["sensor.energy_water"][0].change).toBeCloseTo(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["2026-02-01", "2026-03-01", 6720],
|
||||
["2024-02-01", "2024-03-01", 6960],
|
||||
["2026-04-01", "2026-05-01", 7200],
|
||||
])("uses the actual month duration from %s", (start, end, expected) => {
|
||||
vi.setSystemTime(new Date("2027-01-01T00:00:00Z"));
|
||||
const stats = fetchWater(
|
||||
"month",
|
||||
undefined,
|
||||
`${start}T00:00:00Z`,
|
||||
`${end}T00:00:00Z`
|
||||
);
|
||||
expect(stats["sensor.energy_water"][0].change).toBeCloseTo(expected);
|
||||
});
|
||||
|
||||
it("scales each month independently in a multi-month request", () => {
|
||||
vi.setSystemTime(new Date("2027-01-01T00:00:00Z"));
|
||||
const stats = fetchWater(
|
||||
"month",
|
||||
undefined,
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2026-04-01T00:00:00Z"
|
||||
);
|
||||
expect(stats["sensor.energy_water"].map((bucket) => bucket.change)).toEqual(
|
||||
[7440, 6720, 7440]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["sensor.energy_water", "sensor.energy_water_cost"],
|
||||
["sensor.energy_water_cost", "sensor.energy_water"],
|
||||
["sensor.energy_water_cost"],
|
||||
])("derives costs regardless of requested IDs: %j", (...ids) => {
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.25).mockReturnValue(0.5);
|
||||
const stats = fetchWater("hour", ids);
|
||||
const costs = stats["sensor.energy_water_cost"];
|
||||
expect(costs[0].change).toBeCloseTo(5 * 0.004);
|
||||
let total = 0;
|
||||
costs.forEach((cost, index) => {
|
||||
const consumption = stats["sensor.energy_water"]?.[index];
|
||||
if (consumption) {
|
||||
expect(cost.change).toBeCloseTo(consumption.change! * 0.004);
|
||||
}
|
||||
total += cost.change!;
|
||||
expect(cost.sum).toBeCloseTo(total);
|
||||
expect(cost.state).toBeCloseTo(total);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { AutomationConfig } from "../../../../src/data/automation";
|
||||
import { PANEL_TIMEOUT, QUICK_TIMEOUT } from "../../helpers";
|
||||
import { goToPanel } from "./helpers";
|
||||
|
||||
export const automationPicker = (page: Page) =>
|
||||
page.locator("ha-automation-picker");
|
||||
|
||||
export const automationEditor = (page: Page) =>
|
||||
page.locator("manual-automation-editor");
|
||||
|
||||
// Scoped to the dialog, the editor has a Save button of its own.
|
||||
export const automationSaveDialog = (page: Page) =>
|
||||
page.locator("ha-dialog-automation-save");
|
||||
|
||||
export async function openAutomationPicker(page: Page) {
|
||||
await goToPanel(page, "/?scenario=automation-editor#/config/automation");
|
||||
await expect(automationPicker(page)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startNewAutomation(page: Page) {
|
||||
await automationPicker(page)
|
||||
.getByRole("button", { name: "Create automation" })
|
||||
.click();
|
||||
await page
|
||||
.locator("ha-dialog-new-automation ha-list-item")
|
||||
.filter({ hasText: "Create new automation" })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/#\/config\/automation\/edit\/new$/);
|
||||
await expect(automationEditor(page)).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
// Picks the element through the dialog's search, which is the same on wide
|
||||
// and narrow layouts, unlike browsing its groups.
|
||||
export async function addAutomationElement(
|
||||
page: Page,
|
||||
type: "trigger" | "action",
|
||||
name: string
|
||||
) {
|
||||
await automationEditor(page)
|
||||
.getByRole("button", { name: `Add ${type}`, exact: true })
|
||||
.click();
|
||||
const dialog = page.locator("add-automation-element-dialog");
|
||||
await dialog.locator("ha-input-search input").fill(name);
|
||||
await dialog
|
||||
.locator("ha-automation-add-search ha-list-item-button")
|
||||
.filter({ hasText: name })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.locator("ha-input-search")).toBeHidden({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
// The new element opens in the sidebar, which is a bottom sheet over the
|
||||
// Save button on narrow layouts, so close it like a user would.
|
||||
const sidebar = automationEditor(page).locator("ha-automation-sidebar");
|
||||
await sidebar.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sidebar).toHaveClass(/hidden/);
|
||||
}
|
||||
|
||||
// The host element, which carries the "dirty" class while there are unsaved
|
||||
// changes.
|
||||
export const saveAutomationButton = (page: Page) =>
|
||||
automationEditor(page).locator('ha-button[slot="fab"]');
|
||||
|
||||
export const expectAutomationSaveDialog = async (
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<Locator> => {
|
||||
const dialog = automationSaveDialog(page);
|
||||
await expect(dialog.getByRole("dialog", { name: title })).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
return dialog;
|
||||
};
|
||||
|
||||
export const readSavedAutomation = (page: Page, id: string) =>
|
||||
page.evaluate(
|
||||
(automationId) =>
|
||||
window.__mockHass.callApi<AutomationConfig>(
|
||||
"GET",
|
||||
`config/automation/config/${automationId}`
|
||||
),
|
||||
id
|
||||
);
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEnergy } from "../../../../demo/src/stubs/energy";
|
||||
import { energyEntities } from "../../../../demo/src/stubs/entities";
|
||||
import {
|
||||
mockEntityRegistry,
|
||||
mockEntityRegistryDisplay,
|
||||
} from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockEvents } from "../../../../demo/src/stubs/events";
|
||||
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
|
||||
@@ -171,7 +168,6 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
mockDeviceRegistry(hass);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, []);
|
||||
mockConfigEntries(hass);
|
||||
mockIcons(hass);
|
||||
|
||||
@@ -372,20 +372,6 @@ const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
|
||||
hass.mockWS("media_source/browse_media", () => browsePromise);
|
||||
};
|
||||
|
||||
const automationEditorScenario: Scenario = async (hass) => {
|
||||
// A direct load of an automation route otherwise races the config panel
|
||||
// translations that the editor's buttons and dialogs are named by.
|
||||
await hass.loadFragmentTranslation("config");
|
||||
// Like a default install, so creating an automation starts in the dialog
|
||||
// that also offers blueprints.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "blueprint"],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const systemLogReportingScenario: Scenario = async (hass) => {
|
||||
await hass.loadFragmentTranslation("config");
|
||||
hass.updateHass({
|
||||
@@ -511,7 +497,6 @@ const systemLogReportingScenario: Scenario = async (hass) => {
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
default: defaultScenario,
|
||||
"automation-editor": automationEditorScenario,
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* E2E tests for creating automations in the HA test app (port 8095).
|
||||
*
|
||||
* Run with:
|
||||
* pnpm test:e2e:app -g "Automation editor"
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
addAutomationElement,
|
||||
automationEditor,
|
||||
automationPicker,
|
||||
automationSaveDialog,
|
||||
expectAutomationSaveDialog,
|
||||
openAutomationPicker,
|
||||
readSavedAutomation,
|
||||
saveAutomationButton,
|
||||
startNewAutomation,
|
||||
} from "./app/src/automation";
|
||||
import {
|
||||
expectNoPageErrors,
|
||||
PANEL_TIMEOUT,
|
||||
QUICK_TIMEOUT,
|
||||
trackPageErrors,
|
||||
} from "./helpers";
|
||||
|
||||
test.describe("Automation editor", () => {
|
||||
let errors: ReturnType<typeof trackPageErrors>;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = trackPageErrors(page);
|
||||
await openAutomationPicker(page);
|
||||
await startNewAutomation(page);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
|
||||
test("creates an automation through the save dialog", async ({ page }) => {
|
||||
await addAutomationElement(page, "trigger", "Home Assistant");
|
||||
await addAutomationElement(page, "action", "Wait for time to pass (delay)");
|
||||
|
||||
await saveAutomationButton(page).click();
|
||||
const dialog = await expectAutomationSaveDialog(page, "Save");
|
||||
const name = dialog.getByRole("textbox", { name: "Name" });
|
||||
await expect(name).toHaveValue("New automation");
|
||||
await name.fill("E2E test automation");
|
||||
await dialog.getByRole("button", { name: "Add description" }).click();
|
||||
await dialog
|
||||
.getByRole("textbox", { name: "Description" })
|
||||
.fill("Created by Playwright");
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/#\/config\/automation\/edit\/\d+$/, {
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
const id = page.url().split("/").pop()!;
|
||||
await expect(dialog.getByRole("dialog")).toBeHidden();
|
||||
// Shown when the new automation never reaches the entity registry.
|
||||
await expect(
|
||||
page.locator("dialog-automation-save-timeout")
|
||||
).not.toBeAttached();
|
||||
await expect(page.locator("hass-subpage .main-title")).toHaveText(
|
||||
"E2E test automation"
|
||||
);
|
||||
await expect(saveAutomationButton(page)).not.toHaveClass(/dirty/);
|
||||
|
||||
expect(await readSavedAutomation(page, id)).toEqual({
|
||||
id,
|
||||
alias: "E2E test automation",
|
||||
description: "Created by Playwright",
|
||||
mode: "single",
|
||||
triggers: [{ trigger: "homeassistant", event: "start" }],
|
||||
conditions: [],
|
||||
actions: [{ delay: expect.anything() }],
|
||||
});
|
||||
|
||||
await page.goBack();
|
||||
await expect(
|
||||
automationPicker(page).getByRole("row", { name: /E2E test automation/ })
|
||||
).toBeVisible({ timeout: PANEL_TIMEOUT });
|
||||
});
|
||||
|
||||
test("keeps the save dialog open without a name", async ({ page }) => {
|
||||
await addAutomationElement(page, "trigger", "Home Assistant");
|
||||
|
||||
await saveAutomationButton(page).click();
|
||||
const dialog = await expectAutomationSaveDialog(page, "Save");
|
||||
await dialog.getByRole("textbox", { name: "Name" }).clear();
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
dialog.getByText("Cannot save automation without a name")
|
||||
).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
await expect(dialog.getByRole("dialog")).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(dialog.getByRole("dialog")).toBeHidden();
|
||||
await expect(page).toHaveURL(/#\/config\/automation\/edit\/new$/);
|
||||
await expect(saveAutomationButton(page)).toHaveClass(/dirty/);
|
||||
});
|
||||
|
||||
test("discards a new automation when leaving without saving", async ({
|
||||
page,
|
||||
}) => {
|
||||
await addAutomationElement(page, "trigger", "Home Assistant");
|
||||
|
||||
await page.locator("hass-subpage ha-icon-button-arrow-prev").click();
|
||||
const dialog = await expectAutomationSaveDialog(
|
||||
page,
|
||||
"Save new automation?"
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Don't save" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/#\/config\/automation\/dashboard$/, {
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
// The harness has no automations, so the picker is still empty.
|
||||
await expect(
|
||||
automationPicker(page).getByRole("heading", { name: "Start automating" })
|
||||
).toBeVisible();
|
||||
await expect(automationEditor(page)).not.toBeAttached();
|
||||
await expect(automationSaveDialog(page).getByRole("dialog")).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const APP_BASE_URL = `http://localhost:${APP_PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["app.spec.ts", "automation.spec.ts", "onboarding.spec.ts"],
|
||||
testMatch: ["app.spec.ts", "onboarding.spec.ts"],
|
||||
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShowToastParams } from "../../src/managers/notification-manager";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
reloadOnControllerChange,
|
||||
supportsServiceWorker,
|
||||
} from "../../src/util/register-service-worker";
|
||||
|
||||
@@ -55,6 +56,57 @@ describe("supportsServiceWorker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOnControllerChange", () => {
|
||||
let serviceWorker: FakeServiceWorkerContainer;
|
||||
let reload: ReturnType<typeof vi.fn<() => void>>;
|
||||
|
||||
const changeController = () => {
|
||||
serviceWorker.controller = new FakeWorker() as unknown as ServiceWorker;
|
||||
serviceWorker.dispatchEvent(new Event("controllerchange"));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
serviceWorker = new FakeServiceWorkerContainer();
|
||||
reload = vi.fn<() => void>();
|
||||
});
|
||||
|
||||
it("reloads once when a new worker takes over a controlled page", () => {
|
||||
serviceWorker.controller = new FakeWorker() as unknown as ServiceWorker;
|
||||
reloadOnControllerChange(
|
||||
serviceWorker as unknown as ServiceWorkerContainer,
|
||||
reload
|
||||
);
|
||||
|
||||
changeController();
|
||||
changeController();
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not reload for the first install's claim", () => {
|
||||
reloadOnControllerChange(
|
||||
serviceWorker as unknown as ServiceWorkerContainer,
|
||||
reload
|
||||
);
|
||||
|
||||
changeController();
|
||||
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads for an update after the first install's claim", () => {
|
||||
reloadOnControllerChange(
|
||||
serviceWorker as unknown as ServiceWorkerContainer,
|
||||
reload
|
||||
);
|
||||
|
||||
changeController();
|
||||
changeController();
|
||||
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
let root: HTMLElement;
|
||||
let worker: FakeWorker;
|
||||
|
||||
Reference in New Issue
Block a user