Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld e27148f68c Fetch the index fresh on navigation and harden the service worker update flow
Document navigations used StaleWhileRevalidate, so after an upgrade the
service worker handed the browser the previous build's index.html, which
imports content-hashed bundles that no longer exist. Deep links were cached
in file-cache, which no install ever cleared, so those stale copies
survived every upgrade. A core restart could also pin an index rendered
before setup finished (missing extra_module_url imports) for two loads.

- Route every document navigation through NetworkFirst with a 3s timeout,
  in the runtime cache that install clears and warms. The cached index is
  now only an offline fallback.
- Enable navigation preload on activate, so the browser starts the index
  fetch while the worker boots and the fresh fetch costs no extra round
  trip over the old cache-first paint.
- Swap a 5xx response from a reverse proxy or the cloud for the cached app
  shell when there is one, so the shell still reconnects on its own during
  a restart. This runs on the final response, which also covers preloaded
  navigations that workbox returns without consulting fetch plugins.
- Await the install-time refetch of "/" inside waitUntil, and let the
  install succeed when that refetch fails.
- Reload at most once on controllerchange, and not for the claim that
  follows the first install, since that page already runs the build it
  registered.

Fixes home-assistant/frontend#53208
2026-10-09 17:14:58 +02:00
19 changed files with 638 additions and 970 deletions
+4 -4
View File
@@ -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:
+4 -33
View File
@@ -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
View File
@@ -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"
}
+474 -492
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -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",
+52 -18
View File
@@ -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
+4 -5
View File
@@ -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))
);
};
+24 -4
View File
@@ -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);
});
});
-99
View File
@@ -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);
});
});
});
-89
View File
@@ -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
);
+1 -5
View File
@@ -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);
-15
View File
@@ -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,
-125
View File
@@ -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();
});
});
+1 -1
View File
@@ -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 },
+52
View File
@@ -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;