Compare commits

..

4 Commits

Author SHA1 Message Date
AlCalzone 7e244a706b Z-Wave: create users with credentials in a single API call (#52339)
* Z-Wave: use add_user service to add users with credentials

* refactor to match backend changes
2026-07-09 06:27:25 -04:00
renovate[bot] 7911aef6c0 Update vitest monorepo to v4.1.10 (#53067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:44:12 +03:00
renovate[bot] ed00d4ded1 Update dependency @codemirror/view to v6.43.6 (#53066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:43:36 +03:00
Petar Petrov be55a2197e Wrap device automation picker option labels instead of truncating (#53065) 2026-07-09 10:30:28 +02:00
31 changed files with 1117 additions and 1509 deletions
-4
View File
@@ -18,10 +18,6 @@ runs:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Install dependencies
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
+40 -162
View File
@@ -38,9 +38,8 @@ jobs:
- name: Build demo
uses: ./.github/actions/build
with:
target: build-demo-e2e
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -66,9 +65,8 @@ jobs:
- name: Build e2e test app
uses: ./.github/actions/build
with:
target: build-e2e-test-app-e2e
target: build-e2e-test-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -96,7 +94,6 @@ jobs:
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -106,27 +103,15 @@ jobs:
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests against Chromium ──────────────────────────────────
e2e-demo:
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-demo
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
shardTotal:
- 2
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -136,133 +121,60 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: demo-dist
path: demo/dist/
- name: Run Playwright demo tests
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
path: test/e2e/reports/demo/
if-no-files-found: warn
retention-days: 3
e2e-app:
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-e2e-test-app
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Run Playwright app tests
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
path: test/e2e/reports/app/
if-no-files-found: warn
retention-days: 3
e2e-gallery:
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
needs:
- build-gallery
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
options: --user 1001 --ipc=host
defaults:
run:
shell: bash
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shardIndex:
- 1
- 2
- 3
- 4
shardTotal:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright gallery tests
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload gallery blob report
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-gallery-${{ matrix.shardIndex }}
path: test/e2e/reports/gallery/
if-no-files-found: warn
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs:
- e2e-demo
- e2e-app
- e2e-gallery
needs: [e2e-local]
runs-on: ubuntu-latest
if: ${{ always() }}
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: write
@@ -275,26 +187,12 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Download demo blob reports
- name: Download blob report (local)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
path: test/e2e/reports/gallery/
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
@@ -311,11 +209,7 @@ jobs:
retention-days: 14
- name: Post report to PR
if: >-
github.event_name == 'pull_request' &&
(needs.e2e-demo.result == 'failure' ||
needs.e2e-app.result == 'failure' ||
needs.e2e-gallery.result == 'failure')
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
@@ -323,19 +217,3 @@ jobs:
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
- name: Check suite results
run: |
failed=0
for suite in \
"demo:${{ needs.e2e-demo.result }}" \
"app:${{ needs.e2e-app.result }}" \
"gallery:${{ needs.e2e-gallery.result }}"; do
name="${suite%%:*}"
result="${suite#*:}"
echo "E2E ${name}: ${result}"
if [ "$result" != "success" ]; then
failed=1
fi
done
exit "$failed"
+2 -4
View File
@@ -232,7 +232,7 @@ module.exports.config = {
};
},
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
demo({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "demo" + nameSuffix(latestBuild),
entry: {
@@ -247,7 +247,6 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
@@ -307,7 +306,7 @@ module.exports.config = {
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
@@ -322,7 +321,6 @@ module.exports.config = {
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
};
},
};
-16
View File
@@ -42,22 +42,6 @@ gulp.task(
)
);
gulp.task(
"build-demo-e2e",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-demo",
"rspack-prod-demo-e2e",
"gen-pages-demo-prod-e2e"
)
);
gulp.task(
"analyze-demo",
gulp.series(
-15
View File
@@ -39,18 +39,3 @@ gulp.task(
"gen-pages-e2e-test-app-prod"
)
);
gulp.task(
"build-e2e-test-app-e2e",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app-e2e",
"gen-pages-e2e-test-app-prod"
)
);
-10
View File
@@ -225,16 +225,6 @@ gulp.task(
)
);
gulp.task(
"gen-pages-demo-prod-e2e",
genPagesProdTask(
DEMO_PAGE_ENTRIES,
paths.demo_dir,
paths.demo_output_root,
paths.demo_output_latest
)
);
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
-24
View File
@@ -177,18 +177,6 @@ gulp.task("rspack-prod-demo", () =>
bothBuilds(createDemoConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-demo-e2e", () =>
prodBuild(
createDemoConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
@@ -281,18 +269,6 @@ gulp.task("rspack-prod-e2e-test-app", () =>
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
prodBuild(
createE2eTestAppConfig({
isProdBuild: true,
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
})
)
);
+4 -19
View File
@@ -387,14 +387,9 @@ const createAppConfig = ({
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
);
const createDemoConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
);
const createCastConfig = ({ isProdBuild, latestBuild }) =>
@@ -406,19 +401,9 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
}) =>
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({
isProdBuild,
latestBuild,
isStatsBuild,
isTestBuild,
})
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
+3 -3
View File
@@ -49,7 +49,7 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.5",
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
@@ -168,7 +168,7 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -212,7 +212,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.62.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
-16
View File
@@ -58,17 +58,6 @@
"depNameTemplate": "rhysd/actionlint",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
}
],
"packageRules": [
@@ -97,11 +86,6 @@
"description": "Group date-fns with dependent timezone package",
"groupName": "date-fns",
"matchPackageNames": ["date-fns", "date-fns-tz"]
},
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
}
]
}
@@ -1,3 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -15,7 +16,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
}
);
// Device automation labels (entity name + subtype) are often longer than the
// field, so let the option wrap onto multiple lines instead of truncating.
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
html`<ha-combo-box-item type="button" compact multiline>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
</ha-combo-box-item>`;
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
+9
View File
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
@property({ type: Boolean, reflect: true, attribute: "border-top" })
public borderTop = false;
// Allow the headline/supporting text to wrap onto multiple lines instead of
// truncating with an ellipsis. Off by default to preserve single-line rows.
@property({ type: Boolean, reflect: true })
public multiline = false;
static override styles = [
...haMdListStyles,
css`
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
font-size: var(--ha-font-size-s);
white-space: nowrap;
}
:host([multiline]) [slot="headline"],
:host([multiline]) [slot="supporting-text"] {
white-space: normal;
}
::slotted(state-badge),
::slotted(img) {
width: 32px;
+4
View File
@@ -117,10 +117,14 @@ export interface SetZwaveUserParams {
user_type?: string;
credential_rule?: string;
active?: boolean;
credential_type?: ZwaveCredentialType;
credential_slot?: number;
credential_data?: string;
}
export interface SetZwaveUserResult {
user_id: number;
credential_slot?: number | null;
}
export interface SetZwaveCredentialParams {
@@ -19,7 +19,6 @@ import {
DEFAULT_CREDENTIAL_MIN_LENGTH,
ENTERABLE_ZWAVE_CREDENTIAL_TYPES,
deleteZwaveCredential,
deleteZwaveUser,
enterableCredentialTypes,
getCredentialError,
compatibleUserTypes,
@@ -539,27 +538,16 @@ class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFo
credentialType: ZwaveCredentialType
): Promise<void> {
const params = this._params!;
const { user_id } = await setZwaveUser(this.hass, params.entity_id, {
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
user_type: this._userType,
active: true,
});
try {
await setZwaveCredential(this.hass, params.entity_id, {
user_id,
await setZwaveUser(this.hass, params.entity_id, {
// Omit user_id to create a new user with the given credential.
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
user_type: this._userType,
active: true,
credential_type: credentialType,
credential_data: this._credentialData,
});
} catch (err: unknown) {
// Roll back the user so the lock returns to its prior state. We
// ignore rollback errors — the credential error is the actionable
// one to surface; a stranded user will reappear on next refresh.
try {
await deleteZwaveUser(this.hass, params.entity_id, user_id);
} catch {
// Ignore.
}
this._error = this.hass.localize(
"ui.panel.config.zwave_js.credentials.errors.add_user_failed",
{
+43
View File
@@ -169,6 +169,49 @@ describe("zwave_js-credentials", () => {
);
expect(result.user_id).toBe(1);
});
it("creates a new user with a credential and returns the slot", async () => {
// Omitting user_id makes set_user create a new user and write the
// credential in one call, returning the allocated credential_slot.
const hass = setUserResponse({ user_id: 1, credential_slot: 1 });
const result = await setZwaveUser(hass, ENTITY_ID, {
user_name: "Alice",
user_type: "general",
active: true,
credential_type: "pin_code",
credential_data: "1234",
});
expect(hass.callService).toHaveBeenCalledWith(
"zwave_js",
"set_user",
{
user_name: "Alice",
user_type: "general",
active: true,
credential_type: "pin_code",
credential_data: "1234",
},
{ entity_id: ENTITY_ID },
false,
true
);
expect(result).toEqual({ user_id: 1, credential_slot: 1 });
});
it("propagates errors from callService", async () => {
const hass = {
callService: vi.fn().mockRejectedValue(new Error("No slots")),
} as unknown as HomeAssistant;
await expect(
setZwaveUser(hass, ENTITY_ID, {
credential_type: "pin_code",
credential_data: "1234",
})
).rejects.toThrow("No slots");
});
});
describe("deleteZwaveUser", () => {
+463 -69
View File
@@ -4,29 +4,164 @@
* Run with:
* yarn test:e2e:app
*/
import { test, expect } from "@playwright/test";
import {
appSidebar,
appSidebarConfig,
appSidebarPanel,
assertElementContent,
defineLinkSmokeTests,
defineRouteSmokeTests,
ensureAppSidebarPanelVisible,
goToPanel,
} from "./app/src/helpers";
import {
expectNoPageErrors,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "./helpers";
import {
appRouteSmokeGroups,
configLinks,
moreInfoViewElements,
} from "./app/src/smoke";
import { test, expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
import { e2ePanelRouteAssertions } from "./app/src/ha-test-panels";
/**
* Each More info view renders one root element inside the dialog, plus one or
* more characteristic descendants that prove the view actually populated rather
* than rendering an empty shell. `text`, when set, asserts the element's text
* instead of just its presence.
*/
const MORE_INFO_VIEW_ELEMENTS: {
view: MoreInfoView;
element: string;
content: { selector: string; text?: string }[];
}[] = [
{
view: "info",
element: "ha-more-info-info",
content: [
{ selector: "more-info-light" },
{ selector: "span.title", text: "Test Light" },
],
},
{
view: "history",
element: "ha-more-info-history-and-logbook",
// The demo loads the history component but not logbook.
content: [{ selector: "ha-more-info-history" }],
},
{
view: "settings",
element: "ha-more-info-settings",
// The scenario mocks config/entity_registry/get, so the real registry
// panel renders instead of the "no unique ID" warning.
content: [{ selector: "entity-registry-settings" }],
},
{
view: "related",
element: "ha-related-items",
// search/related is mocked to return no relations, so the empty list
// renders.
content: [{ selector: "ha-related-items >> ha-list" }],
},
{
view: "add_to",
element: "ha-more-info-add-to",
// Admin users get the default add-to action list.
content: [{ selector: "ha-add-to-action-list" }],
},
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
const URL_NORMALIZATION_ASSERTIONS: {
name: string;
path: string;
element: string;
url: RegExp;
action?: (page: Page) => Promise<void>;
}[] = [
{
name: "keeps the todo panel when adding the selected entity query",
path: "/todo",
element: "ha-panel-todo",
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
},
{
name: "keeps the history panel when removing the back query",
path: "/?back=1#/history",
element: "ha-panel-history, history-panel",
url: /\/#\/history$/,
},
{
name: "keeps the logbook panel when removing the back query",
path: "/?back=1#/logbook",
element: "ha-panel-logbook",
url: /\/#\/logbook$/,
},
{
name: "keeps the lovelace panel when adding the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/\?edit=1#\/lovelace\/home$/,
action: (page) => setLovelaceEditMode(page, true),
},
{
name: "keeps the lovelace panel when removing the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/#\/lovelace\/home$/,
action: async (page) => {
await setLovelaceEditMode(page, true);
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
timeout: SHELL_TIMEOUT,
});
await setLovelaceEditMode(page, false);
},
},
];
interface E2ELovelaceRoot extends HTMLElement {
lovelace?: {
setEditMode: (editMode: boolean) => void;
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// The test app is built with __DEMO__=true which enables hash-based routing.
// Panel paths must use hash URLs: /#/lovelace, /#/energy, etc.
// Scenario selection uses query params: /?scenario=foo (always at root).
/** Navigate to a panel (hash routing) and wait for app to initialize. */
async function goToPanel(page: Page, path: string) {
// Paths starting with /? are root-level (scenario selection); panel paths
// need to use hash routing (/#/panelname).
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await page.waitForSelector("ha-test", { state: "attached" });
// Wait for the app to finish initialising (hassConnected sets panels)
await page.waitForFunction(() => Boolean((window as any).__mockHass));
}
async function setLovelaceEditMode(page: Page, editMode: boolean) {
await page
.locator("hui-root")
.first()
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
await page
.locator("hui-root")
.first()
.evaluate(async (el: Element, value) => {
const root = el as E2ELovelaceRoot;
const start = performance.now();
await new Promise<void>((resolve, reject) => {
const check = () => {
if (root.lovelace?.setEditMode) {
resolve();
return;
}
if (performance.now() - start > 2000) {
reject(new Error("Lovelace edit mode action was not available"));
return;
}
requestAnimationFrame(check);
};
check();
});
root.lovelace!.setEditMode(value);
}, editMode);
}
// ---------------------------------------------------------------------------
// App shell
@@ -34,40 +169,60 @@ import {
test.describe("App shell", () => {
test("page loads and ha-test element mounts", async ({ page }) => {
const errors = trackPageErrors(page);
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/");
await expect(page.locator("ha-test")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
expectNoPageErrors(errors);
await expect(page.locator("ha-test")).toBeAttached();
expect(errors).toHaveLength(0);
});
test("sidebar renders with expected panels", async ({ page }) => {
await goToPanel(page, "/lovelace");
await Promise.all([
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root.
...["lovelace", "map", "energy", "history"].map((urlPath) =>
expect(appSidebarPanel(page, urlPath)).toBeAttached({
timeout: QUICK_TIMEOUT,
})
),
// Config has its own special element with id="sidebar-config".
expect(appSidebarConfig(page)).toBeAttached({
timeout: QUICK_TIMEOUT,
}),
]);
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
for (const urlPath of ["lovelace", "map", "energy", "history"]) {
// eslint-disable-next-line no-await-in-loop
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-panel-${urlPath}`
)
).toBeAttached();
}
// Config has its own special element with id="sidebar-config"
await expect(
page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
)
).toBeAttached();
});
test("sidebar navigation changes the active panel", async ({ page }) => {
await goToPanel(page, "/lovelace");
const historyLink = await ensureAppSidebarPanelVisible(page, "history");
await historyLink.click({ force: true });
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(page).toHaveURL(/\/#\/history$/, { timeout: QUICK_TIMEOUT });
const historyLink = sidebar.locator("#sidebar-panel-history");
if (!(await historyLink.isVisible().catch(() => false))) {
await page.locator("ha-test >> home-assistant-main").evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-toggle-menu", {
detail: { open: true },
bubbles: true,
composed: true,
})
);
});
}
await expect(historyLink).toBeVisible({ timeout: SHELL_TIMEOUT });
await historyLink.click();
await expect(page).toHaveURL(/\/#\/history$/, { timeout: SHELL_TIMEOUT });
await expect(
page.locator("ha-panel-history, history-panel").first()
).toBeAttached({ timeout: PANEL_TIMEOUT });
@@ -76,29 +231,34 @@ test.describe("App shell", () => {
test("sidebar renders notification badge", async ({ page }) => {
await goToPanel(page, "/lovelace");
const sidebar = appSidebar(page);
await expect(sidebar).toBeAttached({ timeout: QUICK_TIMEOUT });
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
await expect(sidebar).toBeAttached({ timeout: SHELL_TIMEOUT });
const notificationsLink = sidebar.locator("#sidebar-notifications");
await expect(notificationsLink).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(notificationsLink).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(notificationsLink.locator(".badge").first()).toHaveText("1", {
timeout: QUICK_TIMEOUT,
timeout: SHELL_TIMEOUT,
});
});
test("sidebar marks the active panel as selected", async ({ page }) => {
const lovelaceLink = appSidebarPanel(page, "lovelace");
const historyLink = appSidebarPanel(page, "history");
const sidebar = page.locator(
"ha-test >> home-assistant-main >> ha-sidebar"
);
const lovelaceLink = sidebar.locator("#sidebar-panel-lovelace");
const historyLink = sidebar.locator("#sidebar-panel-history");
await goToPanel(page, "/lovelace");
await expect(lovelaceLink).toHaveClass(/selected/, {
timeout: QUICK_TIMEOUT,
timeout: SHELL_TIMEOUT,
});
await expect(historyLink).not.toHaveClass(/selected/);
await goToPanel(page, "/history");
await expect(historyLink).toHaveClass(/selected/, {
timeout: QUICK_TIMEOUT,
timeout: SHELL_TIMEOUT,
});
await expect(lovelaceLink).not.toHaveClass(/selected/);
});
@@ -111,16 +271,124 @@ test.describe("App shell", () => {
await goToPanel(page, "/?scenario=non-admin#/lovelace");
// Wait for the sidebar to mount before asserting on its contents.
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(
page.locator("ha-test >> home-assistant-main >> ha-sidebar")
).toBeAttached({ timeout: SHELL_TIMEOUT });
// Config panel is adminOnly — should not appear for non-admin.
await expect(appSidebarConfig(page)).not.toBeAttached({
timeout: QUICK_TIMEOUT,
const configLink = page.locator(
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
);
await expect(configLink).not.toBeAttached();
});
});
// ---------------------------------------------------------------------------
// Panel navigation
// ---------------------------------------------------------------------------
test.describe("Panel navigation", () => {
for (const [path, element] of e2ePanelRouteAssertions) {
test(`renders registered panel ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
test.describe("Panel URL normalization", () => {
for (const {
name,
path,
element,
url,
action,
} of URL_NORMALIZATION_ASSERTIONS) {
test(name, async ({ page }) => {
await goToPanel(page, path);
await action?.(page);
await expect(page).toHaveURL(url, { timeout: SHELL_TIMEOUT });
await expect(page.locator(element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
// ---------------------------------------------------------------------------
// Tools panel (formerly Developer tools)
// ---------------------------------------------------------------------------
/**
* Every tool sub-page reachable under /config/tools, mapped to the custom
* element tools-router mounts for it (see tools-router.ts). Asserting on the
* specific element proves the route actually rendered its tool, not just the
* shared ha-panel-tools shell.
*/
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
{ route: "yaml", element: "tools-yaml-config" },
{ route: "state", element: "tools-state" },
{ route: "action", element: "tools-action" },
{ route: "template", element: "tools-template" },
{ route: "event", element: "tools-event" },
{ route: "statistics", element: "tools-statistics" },
{ route: "assist", element: "tools-assist" },
{ route: "debug", element: "tools-debug" },
];
test.describe("Tools panel", () => {
test("base path renders the tools panel", async ({ page }) => {
await goToPanel(page, "/config/tools");
await expect(page.locator("ha-panel-tools")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
for (const { route, element } of TOOLS_SUBPAGES) {
test(`renders the ${route} sub-page`, async ({ page }) => {
await goToPanel(page, `/config/tools/${route}`);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
test("service is an alias for the action tool", async ({ page }) => {
await goToPanel(page, "/config/tools/service");
await expect(page.locator("tools-action")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
});
defineRouteSmokeTests(appRouteSmokeGroups);
// ---------------------------------------------------------------------------
// Tools redirects (old developer-tools URLs)
// ---------------------------------------------------------------------------
test.describe("Tools redirects", () => {
// The panel moved from top-level /developer-tools (pre-2026.2) to
// /config/developer-tools (2026.2), then was renamed to /config/tools
// (2026.8). Both old locations must redirect to the new one, and deep links
// must keep their sub-page. See the updateRoute() redirect in
// src/layouts/home-assistant.ts.
for (const oldBase of ["/developer-tools", "/config/developer-tools"]) {
test(`redirects ${oldBase} to the tools panel`, async ({ page }) => {
await goToPanel(page, oldBase);
await expect(page.locator("ha-panel-tools")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
test(`redirects ${oldBase}/state to the state tool`, async ({ page }) => {
await goToPanel(page, `${oldBase}/state`);
await expect(page.locator("tools-state")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
// ---------------------------------------------------------------------------
// Lovelace
@@ -149,7 +417,7 @@ test.describe("Lovelace dashboard", () => {
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
for (const { view, element, content } of moreInfoViewElements) {
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
test(`opens more-info ${view} view for a light entity`, async ({
page,
}) => {
@@ -187,7 +455,16 @@ test.describe("Light more-info dialog", () => {
// Each view should render its own characteristic content, not just an
// empty shell.
await assertElementContent(dialog, content);
for (const { selector, text } of content) {
const locator = dialog.locator(selector).first();
if (text) {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT });
} else {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
}
}
});
}
});
@@ -242,27 +519,144 @@ test.describe("Theming", () => {
// ---------------------------------------------------------------------------
test.describe("Config panel", () => {
const DASHBOARD_LINKS = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
const CONFIG_ROUTES: { path: string; element: string }[] = [
{ path: "/config/integrations", element: "ha-config-integrations" },
{ path: "/config/devices", element: "ha-config-devices" },
{ path: "/config/entities", element: "ha-config-entities" },
{ path: "/config/helpers", element: "ha-config-helpers" },
{ path: "/config/areas", element: "ha-config-areas" },
{ path: "/config/apps", element: "ha-config-apps" },
{ path: "/config/app", element: "ha-config-app-dashboard" },
{ path: "/config/automation", element: "ha-config-automation" },
{ path: "/config/backup", element: "ha-config-backup" },
{ path: "/config/scene", element: "ha-config-scene" },
{ path: "/config/script", element: "ha-config-script" },
{ path: "/config/blueprint", element: "ha-config-blueprint" },
{ path: "/config/cloud", element: "ha-config-cloud" },
{ path: "/config/energy", element: "ha-config-energy" },
{ path: "/config/hardware", element: "ha-config-hardware" },
{ path: "/config/labs", element: "ha-config-labs" },
{ path: "/config/lovelace", element: "ha-config-lovelace" },
{ path: "/config/person", element: "ha-config-person" },
{ path: "/config/storage", element: "ha-config-section-storage" },
{ path: "/config/tags", element: "ha-config-tags" },
{ path: "/config/users", element: "ha-config-users" },
{ path: "/config/voice-assistants", element: "ha-config-voice-assistants" },
{ path: "/config/system", element: "ha-config-system-navigation" },
{ path: "/config/info", element: "ha-config-info" },
{ path: "/config/logs", element: "ha-config-logs" },
{ path: "/config/general", element: "ha-config-section-general" },
{ path: "/config/updates", element: "ha-config-section-updates" },
{ path: "/config/repairs", element: "ha-config-repairs-dashboard" },
{ path: "/config/analytics", element: "ha-config-section-analytics" },
{ path: "/config/ai-tasks", element: "ha-config-section-ai-tasks" },
{ path: "/config/labels", element: "ha-config-labels" },
{ path: "/config/zone", element: "ha-config-zone" },
{ path: "/config/network", element: "ha-config-section-network" },
{
path: "/config/application_credentials",
element: "ha-config-application-credentials",
},
{ path: "/config/bluetooth", element: "bluetooth-config-dashboard-router" },
{ path: "/config/dhcp", element: "dhcp-config-panel" },
{ path: "/config/infrared", element: "infrared-config-dashboard-router" },
{ path: "/config/matter", element: "matter-config-panel" },
{ path: "/config/mqtt", element: "mqtt-config-panel" },
{
path: "/config/radio-frequency",
element: "radio-frequency-config-dashboard-router",
},
{ path: "/config/ssdp", element: "ssdp-config-panel" },
{ path: "/config/thread", element: "thread-config-panel" },
{ path: "/config/zeroconf", element: "zeroconf-config-panel" },
{ path: "/config/zha", element: "zha-config-dashboard-router" },
{ path: "/config/zwave_js", element: "zwave_js-config-router" },
];
const NESTED_CONFIG_ROUTES: { path: string; element: string }[] = [
{
path: "/config/integrations/dashboard",
element: "ha-config-integrations-dashboard",
},
{
path: "/config/devices/dashboard",
element: "ha-config-devices-dashboard",
},
{ path: "/config/areas/dashboard", element: "ha-config-areas-dashboard" },
{ path: "/config/backup/settings", element: "ha-config-backup-settings" },
];
test("config panel loads without JS errors", async ({ page }) => {
const errors = trackPageErrors(page);
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToPanel(page, "/config");
await expect(
page.locator("ha-panel-config, ha-config-dashboard").first()
).toBeAttached({ timeout: PANEL_TIMEOUT + 5_000 });
expectNoPageErrors(errors);
// Filter known pre-existing errors from vendor code
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
const getDashboard = async (page) => {
test("dashboard renders key settings links", async ({ page }) => {
await goToPanel(page, "/config");
const dashboard = page.locator("ha-config-dashboard");
await expect(dashboard).toBeAttached({ timeout: QUICK_TIMEOUT });
return dashboard;
};
defineLinkSmokeTests(
"config links point to expected pages",
configLinks,
getDashboard
);
const dashboard = page.locator("ha-config-dashboard");
await expect(dashboard).toBeAttached({ timeout: PANEL_TIMEOUT });
for (const { href, label } of DASHBOARD_LINKS) {
const link = dashboard.getByRole("link", {
name: new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
});
// eslint-disable-next-line no-await-in-loop
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
});
for (const { path, element } of CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
for (const { path, element } of NESTED_CONFIG_ROUTES) {
test(`renders ${path}`, async ({ page }) => {
await goToPanel(page, path);
await expect(page.locator(element)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
});
}
});
-186
View File
@@ -1,186 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import {
defineParallelSmokeTests,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
} from "../../helpers";
const APP_MAIN_SELECTOR = "ha-test >> home-assistant-main";
const APP_SIDEBAR_SELECTOR = `${APP_MAIN_SELECTOR} >> ha-sidebar`;
// The app e2e harness is built with __DEMO__=true, which enables hash routing.
// Scenario selection uses query params at root: /?scenario=foo#/lovelace.
export async function goToPanel(page: Page, path: string) {
const url = path.startsWith("/?") ? path : `/#${path}`;
await page.goto(url);
await Promise.all([
page.waitForSelector("ha-test", {
state: "attached",
timeout: SHELL_TIMEOUT,
}),
page.waitForFunction(
() => "__mockHass" in window && Boolean(window.__mockHass),
undefined,
{ timeout: SHELL_TIMEOUT }
),
]);
}
export const appMain = (page: Page) => page.locator(APP_MAIN_SELECTOR);
export const appSidebar = (page: Page) => page.locator(APP_SIDEBAR_SELECTOR);
export const appSidebarPanel = (page: Page, panel: string) =>
appSidebar(page).locator(`#sidebar-panel-${panel}`);
export const appSidebarConfig = (page: Page) =>
appSidebar(page).locator("#sidebar-config");
export async function openAppSidebar(page: Page) {
await appMain(page).evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-toggle-menu", {
detail: { open: true },
bubbles: true,
composed: true,
})
);
});
}
export async function ensureAppSidebarPanelVisible(page: Page, panel: string) {
await expect(appSidebar(page)).toBeAttached({ timeout: QUICK_TIMEOUT });
const link = appSidebarPanel(page, panel);
if (!(await link.isVisible().catch(() => false))) {
await openAppSidebar(page);
}
await expect(link).toBeVisible({ timeout: QUICK_TIMEOUT });
return link;
}
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export interface LinkSmokeCase {
href: string;
label: string;
}
export async function assertLink(
root: Locator,
{ href, label }: LinkSmokeCase
) {
const link = root.getByRole("link", {
name: new RegExp(`^${escapeRegExp(label)}\\b`),
});
await expect(link).toHaveAttribute("href", href, {
timeout: QUICK_TIMEOUT,
});
}
export function defineLinkSmokeTests(
name: string,
links: LinkSmokeCase[],
getRoot: (page: Page) => Promise<Locator>
) {
test(name, async ({ page }) => {
const root = await getRoot(page);
await Promise.all(
links.map((link) =>
test.step(`${link.label} links to ${link.href}`, async () => {
await assertLink(root, link);
})
)
);
});
}
export interface ElementContentAssertion {
selector: string;
text?: string;
}
export interface ViewElementSmokeCase<TView extends string = string> {
view: TView;
element: string;
content: ElementContentAssertion[];
}
export async function assertElementContent(
root: Locator,
content: ElementContentAssertion[]
) {
await Promise.all(
content.map(({ selector, text }) => {
const locator = root.locator(selector).first();
return text
? expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT })
: expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
})
);
}
export interface RouteSmokeCase {
name?: string;
path: string;
element: string;
projects?: string[];
url?: RegExp;
action?: (page: Page) => Promise<void>;
}
export interface RouteSmokeGroup {
name: string;
routes: RouteSmokeCase[];
testName?: (route: RouteSmokeCase) => string;
projects?: string[];
}
export const routeCase = (path: string, element: string): RouteSmokeCase => ({
path,
element,
});
export const routeCases = (routes: [string, string][]): RouteSmokeCase[] =>
routes.map(([path, element]) => routeCase(path, element));
export const rendersRoute = (route: RouteSmokeCase) => `renders ${route.path}`;
async function assertRouteSmoke(page: Page, route: RouteSmokeCase) {
await goToPanel(page, route.path);
await route.action?.(page);
if (route.url) {
await expect(page).toHaveURL(route.url, { timeout: QUICK_TIMEOUT });
}
await expect(page.locator(route.element).first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
}
function shouldRunRouteSmoke(
projectName: string,
group: RouteSmokeGroup,
route: RouteSmokeCase
) {
return (route.projects ?? group.projects)?.includes(projectName) ?? true;
}
export function defineRouteSmokeTests(groups: RouteSmokeGroup[]) {
defineParallelSmokeTests({
groups,
groupName: (group) => group.name,
cases: (group) => group.routes,
testName: (route, group) =>
route.name ?? group.testName?.(route) ?? rendersRoute(route),
run: async ({ page, testInfo, group, smokeCase }) => {
test.skip(
!shouldRunRouteSmoke(testInfo.project.name, group, smokeCase),
"Route smoke coverage does not run for this project"
);
await assertRouteSmoke(page, smokeCase);
},
});
}
-273
View File
@@ -1,273 +0,0 @@
import { expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../../../src/dialogs/more-info/const";
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "../../helpers";
import {
rendersRoute,
routeCase,
routeCases,
type LinkSmokeCase,
type RouteSmokeCase,
type RouteSmokeGroup,
type ViewElementSmokeCase,
} from "./helpers";
import { e2ePanelRouteAssertions } from "./ha-test-panels";
// ── Config dashboard links ───────────────────────────────────────────────────
export const configLinks: LinkSmokeCase[] = [
{ href: "/config/integrations", label: "Devices & services" },
{ href: "/config/automation", label: "Automations & scenes" },
{ href: "/config/areas", label: "Areas, labels & zones" },
{ href: "/config/apps", label: "Apps" },
{ href: "/config/lovelace/dashboards", label: "Dashboards" },
{ href: "/config/voice-assistants", label: "Voice assistants" },
{ href: "/config/matter", label: "Matter" },
{ href: "/config/zha", label: "Zigbee" },
{ href: "/config/zwave_js", label: "Z-Wave" },
{ href: "/knx", label: "KNX" },
{ href: "/config/thread", label: "Thread" },
{ href: "/config/bluetooth", label: "Bluetooth" },
{ href: "/config/infrared", label: "Infrared" },
{ href: "/config/radio-frequency", label: "Radio frequency" },
{ href: "/insteon", label: "Insteon" },
{ href: "/config/tags", label: "Tags" },
{ href: "/config/person", label: "People" },
{ href: "/config/system", label: "System" },
{ href: "/config/tools", label: "Tools" },
{ href: "/config/info", label: "About" },
];
// ── More-info dialog views ───────────────────────────────────────────────────
export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
{
view: "info",
element: "ha-more-info-info",
content: [
{ selector: "more-info-light" },
{ selector: "span.title", text: "Test Light" },
],
},
{
view: "history",
element: "ha-more-info-history-and-logbook",
// The demo loads the history component but not logbook.
content: [{ selector: "ha-more-info-history" }],
},
{
view: "settings",
element: "ha-more-info-settings",
// The scenario mocks config/entity_registry/get, so the real registry
// panel renders instead of the "no unique ID" warning.
content: [{ selector: "entity-registry-settings" }],
},
{
view: "related",
element: "ha-related-items",
// search/related is mocked to return no relations, so the empty list
// renders.
content: [{ selector: "ha-related-items >> ha-list" }],
},
{
view: "add_to",
element: "ha-more-info-add-to",
// Admin users get the default add-to action list.
content: [{ selector: "ha-add-to-action-list" }],
},
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
// ── Route smoke tests ────────────────────────────────────────────────────────
interface E2ELovelaceRoot extends HTMLElement {
lovelace?: {
setEditMode: (editMode: boolean) => void;
};
}
async function setLovelaceEditMode(page: Page, editMode: boolean) {
await page
.locator("hui-root")
.first()
.waitFor({ state: "attached", timeout: QUICK_TIMEOUT });
await page
.locator("hui-root")
.first()
.evaluate(async (el: Element, value) => {
const root = el as E2ELovelaceRoot;
const start = performance.now();
await new Promise<void>((resolve, reject) => {
const check = () => {
if (root.lovelace?.setEditMode) {
resolve();
return;
}
if (performance.now() - start > 2000) {
reject(new Error("Lovelace edit mode action was not available"));
return;
}
requestAnimationFrame(check);
};
check();
});
root.lovelace!.setEditMode(value);
}, editMode);
}
const PANEL_ROUTE_ASSERTIONS = Array.from(
e2ePanelRouteAssertions,
([path, element]) => routeCase(path, element)
);
const URL_NORMALIZATION_ASSERTIONS: RouteSmokeCase[] = [
{
name: "keeps the todo panel when adding the selected entity query",
path: "/todo",
element: "ha-panel-todo",
url: /\/\?entity_id=todo\.shopping_list#\/todo$/,
},
{
name: "keeps the history panel when removing the back query",
path: "/?back=1#/history",
element: "ha-panel-history, history-panel",
url: /\/#\/history$/,
},
{
name: "keeps the logbook panel when removing the back query",
path: "/?back=1#/logbook",
element: "ha-panel-logbook",
url: /\/#\/logbook$/,
},
{
name: "keeps the lovelace panel when removing the edit query",
path: "/lovelace",
element: "ha-panel-lovelace, hui-root",
url: /\/#\/lovelace\/home$/,
action: async (page) => {
await setLovelaceEditMode(page, true);
await expect(page).toHaveURL(/\/\?edit=1#\/lovelace\/home$/, {
timeout: SHELL_TIMEOUT,
});
await setLovelaceEditMode(page, false);
},
},
];
const TOOLS_SUBPAGES: { route: string; element: string }[] = [
{ route: "yaml", element: "tools-yaml-config" },
{ route: "state", element: "tools-state" },
{ route: "action", element: "tools-action" },
{ route: "template", element: "tools-template" },
{ route: "event", element: "tools-event" },
{ route: "statistics", element: "tools-statistics" },
{ route: "assist", element: "tools-assist" },
{ route: "debug", element: "tools-debug" },
];
const TOOLS_ROUTE_ASSERTIONS = [
routeCase("/config/tools", "ha-panel-tools"),
...TOOLS_SUBPAGES.map(({ route, element }) =>
routeCase(`/config/tools/${route}`, element)
),
routeCase("/config/tools/service", "tools-action"),
];
const TOOLS_REDIRECT_ASSERTIONS = [
...["/developer-tools", "/config/developer-tools"].flatMap((oldBase) => [
routeCase(oldBase, "ha-panel-tools"),
routeCase(`${oldBase}/state`, "tools-state"),
]),
];
const CONFIG_ROUTES = routeCases([
["/config/integrations", "ha-config-integrations"],
["/config/devices", "ha-config-devices"],
["/config/entities", "ha-config-entities"],
["/config/helpers", "ha-config-helpers"],
["/config/areas", "ha-config-areas"],
["/config/apps", "ha-config-apps"],
["/config/app", "ha-config-app-dashboard"],
["/config/automation", "ha-config-automation"],
["/config/backup", "ha-config-backup"],
["/config/scene", "ha-config-scene"],
["/config/script", "ha-config-script"],
["/config/blueprint", "ha-config-blueprint"],
["/config/cloud", "ha-config-cloud"],
["/config/energy", "ha-config-energy"],
["/config/hardware", "ha-config-hardware"],
["/config/labs", "ha-config-labs"],
["/config/lovelace", "ha-config-lovelace"],
["/config/person", "ha-config-person"],
["/config/storage", "ha-config-section-storage"],
["/config/tags", "ha-config-tags"],
["/config/users", "ha-config-users"],
["/config/voice-assistants", "ha-config-voice-assistants"],
["/config/system", "ha-config-system-navigation"],
["/config/info", "ha-config-info"],
["/config/logs", "ha-config-logs"],
["/config/general", "ha-config-section-general"],
["/config/updates", "ha-config-section-updates"],
["/config/repairs", "ha-config-repairs-dashboard"],
["/config/analytics", "ha-config-section-analytics"],
["/config/ai-tasks", "ha-config-section-ai-tasks"],
["/config/labels", "ha-config-labels"],
["/config/zone", "ha-config-zone"],
["/config/network", "ha-config-section-network"],
["/config/application_credentials", "ha-config-application-credentials"],
["/config/bluetooth", "bluetooth-config-dashboard-router"],
["/config/dhcp", "dhcp-config-panel"],
["/config/infrared", "infrared-config-dashboard-router"],
["/config/matter", "matter-config-panel"],
["/config/mqtt", "mqtt-config-panel"],
["/config/radio-frequency", "radio-frequency-config-dashboard-router"],
["/config/ssdp", "ssdp-config-panel"],
["/config/thread", "thread-config-panel"],
["/config/zeroconf", "zeroconf-config-panel"],
["/config/zha", "zha-config-dashboard-router"],
["/config/zwave_js", "zwave_js-config-router"],
]);
const NESTED_CONFIG_ROUTES = routeCases([
["/config/integrations/dashboard", "ha-config-integrations-dashboard"],
["/config/devices/dashboard", "ha-config-devices-dashboard"],
["/config/areas/dashboard", "ha-config-areas-dashboard"],
["/config/backup/settings", "ha-config-backup-settings"],
]);
export const appRouteSmokeGroups: RouteSmokeGroup[] = [
{
name: "Panel navigation",
routes: PANEL_ROUTE_ASSERTIONS,
testName: (route) => `renders registered panel ${route.path}`,
},
{
name: "Panel URL normalization",
routes: URL_NORMALIZATION_ASSERTIONS,
testName: (route) => route.name!,
},
{
name: "Tools panel",
routes: TOOLS_ROUTE_ASSERTIONS,
testName: rendersRoute,
},
{
name: "Tools redirects",
routes: TOOLS_REDIRECT_ASSERTIONS,
testName: (route) => `redirects ${route.path}`,
},
{
name: "Config routes",
routes: CONFIG_ROUTES,
testName: rendersRoute,
},
{
name: "Nested config routes",
routes: NESTED_CONFIG_ROUTES,
testName: rendersRoute,
},
];
+5 -27
View File
@@ -5,29 +5,6 @@
// Usage: node test/e2e/collect-blob-reports.mjs
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
import { relative } from "path";
const findBlobReports = (dir) => {
const files = [];
const walk = (currentDir) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const path = `${currentDir}/${entry.name}`;
if (entry.isDirectory()) {
walk(path);
} else if (entry.name.endsWith(".zip")) {
files.push(path);
}
}
};
try {
walk(dir);
} catch {
return undefined;
}
return files;
};
const dest = "test/e2e/reports/blob";
rmSync(dest, { recursive: true, force: true });
@@ -35,8 +12,10 @@ mkdirSync(dest, { recursive: true });
for (const suite of ["demo", "app", "gallery"]) {
const src = `test/e2e/reports/${suite}`;
const files = findBlobReports(src);
if (!files?.length) {
let files;
try {
files = readdirSync(src).filter((f) => f.endsWith(".zip"));
} catch {
// Suite report directory doesn't exist (e.g. job was skipped or failed
// before uploading). Skip gracefully.
process.stderr.write(
@@ -45,7 +24,6 @@ for (const suite of ["demo", "app", "gallery"]) {
continue;
}
for (const file of files) {
const name = relative(src, file).replace(/[\\/]/g, "-");
cpSync(file, `${dest}/${suite}-${name}`);
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
}
}
+107 -39
View File
@@ -1,78 +1,146 @@
import { expect, test } from "@playwright/test";
import {
expectNoPageErrors,
NAVIGATION_TIMEOUT,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
appErrors as filterAppErrors,
} from "./helpers";
import {
clickFirstVisibleDemoSidebarPanel,
demoCardSelector,
moreInfoCardSelector,
openDemoSidebar,
waitForDemoReady,
} from "./demo/helpers";
test.describe("Home Assistant Demo", () => {
// Collect JS errors during each test so we can assert no unexpected crashes.
let pageErrors: Error[] = [];
test.beforeEach(async ({ page }) => {
pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err));
await page.goto("/");
});
function appErrors() {
return filterAppErrors(pageErrors);
}
// ── 1. Page loads ──────────────────────────────────────────────────────────
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
const errors = trackPageErrors(page);
// The custom element is present in the document
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await waitForDemoReady(page);
// The launch screen should disappear once the app is ready
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
expectNoPageErrors(errors);
// No unhandled JS exceptions
expect(appErrors()).toHaveLength(0);
});
test("dashboard renders Lovelace cards", async ({ page }) => {
await waitForDemoReady(page);
// ── 2. Dashboard renders ───────────────────────────────────────────────────
await expect(page.locator(demoCardSelector).first()).toBeVisible({
test("dashboard renders Lovelace cards", async ({ page }) => {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
const cardSelector = [
"hui-tile-card",
"hui-entity-card",
"hui-glance-card",
"hui-button-card",
"hui-markdown-card",
].join(", ");
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: PANEL_TIMEOUT,
});
});
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
test("sidebar navigation changes the active panel", async ({ page }) => {
const errors = trackPageErrors(page);
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
await waitForDemoReady(page);
await openDemoSidebar(page);
// On narrow viewports (< 870 px — mobile / tablet) the sidebar lives
// inside a modal drawer that is closed by default. Open it first via
// the ha-menu-button in the top app-bar.
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
await expect(page.locator("ha-sidebar")).toBeVisible({
timeout: SHELL_TIMEOUT,
});
} else {
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
}
const clicked = await clickFirstVisibleDemoSidebarPanel(page, [
"map",
"logbook",
"history",
"config",
]);
const candidatePanels = ["map", "logbook", "history", "config"];
let clicked = false;
for (const panel of candidatePanels) {
const navItem = page.locator(`#sidebar-panel-${panel}`);
// eslint-disable-next-line no-await-in-loop
const visible = await navItem.isVisible().catch(() => false);
if (visible) {
// eslint-disable-next-line no-await-in-loop
await navItem.click();
// eslint-disable-next-line no-await-in-loop
await expect(page).toHaveURL(new RegExp(`/${panel}`), {
timeout: SHELL_TIMEOUT,
});
clicked = true;
break;
}
}
expect(clicked, "No known sidebar panel was found to click").toBe(true);
expectNoPageErrors(errors);
expect(appErrors()).toHaveLength(0);
});
// ── 4. More info dialog ───────────────────────────────────────────────────
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
const errors = trackPageErrors(page);
await waitForDemoReady(page);
// Tile cards are the most common card type in the demo; fall back to other
// clickable card types in case this platform renders a different layout.
await expect(page.locator(moreInfoCardSelector).first()).toBeVisible({
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await page.locator(moreInfoCardSelector).first().click();
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(dialog.locator("span.title")).toBeVisible({
timeout: QUICK_TIMEOUT,
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
expectNoPageErrors(errors);
// Tile cards are the most common card type in the demo; they open the
// more-info dialog on click. Fall back to other clickable card types in
// case the demo layout on this platform doesn't include tile cards.
const cardSelector =
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
await expect(page.locator(cardSelector).first()).toBeVisible({
timeout: NAVIGATION_TIMEOUT,
});
await page.locator(cardSelector).first().click();
// The more-info dialog is a top-level custom element appended to the body.
// We verify it is attached, then confirm it rendered by checking the title
// span which is slotted into the light DOM and has real layout dimensions.
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
const title = dialog.locator("span.title");
await expect(title).toBeVisible({ timeout: QUICK_TIMEOUT });
expect(appErrors()).toHaveLength(0);
});
});
-69
View File
@@ -1,69 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
export const demoCardSelector = [
"hui-tile-card",
"hui-entity-card",
"hui-glance-card",
"hui-button-card",
"hui-markdown-card",
].join(", ");
export const moreInfoCardSelector =
"hui-tile-card, hui-entity-card, hui-button-card, hui-glance-card";
export async function waitForDemoReady(page: Page) {
await expect(page.locator("ha-demo")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
await expect(page.locator("#ha-launch-screen")).toBeHidden({
timeout: NAVIGATION_TIMEOUT,
});
}
export async function openDemoSidebar(page: Page) {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
await menuButton.click();
await expect(page.locator("ha-sidebar")).toBeVisible({
timeout: SHELL_TIMEOUT,
});
return;
}
await expect(page.locator("ha-sidebar")).toBeAttached({
timeout: NAVIGATION_TIMEOUT,
});
}
export async function clickFirstVisibleDemoSidebarPanel(
page: Page,
panels: string[]
) {
const panelSelectors = panels.map((panel) => `#sidebar-panel-${panel}`);
await expect(page.locator(panelSelectors.join(", ")).first()).toBeVisible({
timeout: SHELL_TIMEOUT,
});
const navItems = await Promise.all(
panels.map(async (panel) => {
const navItem = page.locator(`#sidebar-panel-${panel}`);
return {
panel,
navItem,
isVisible: await navItem.isVisible().catch(() => false),
};
})
);
const visibleNavItem = navItems.find(({ isVisible }) => isVisible);
if (!visibleNavItem) {
return false;
}
await visibleNavItem.navItem.click();
await expect(page).toHaveURL(new RegExp(`/${visibleNavItem.panel}`), {
timeout: SHELL_TIMEOUT,
});
return true;
}
+306 -61
View File
@@ -7,117 +7,362 @@
* Run with:
* yarn test:e2e:gallery
*/
import { test, expect } from "@playwright/test";
import {
expectNoPageErrors,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "./helpers";
import {
defineGallerySmokeTests,
expectGalleryDemoElement,
galleryLocator,
getGalleryDemo,
goToGalleryHome,
GALLERY_IGNORED_PAGE_ERRORS,
} from "./gallery/helpers";
import { componentPages, lovelacePages, moreInfoPages } from "./gallery/pages";
import { test, expect, type Page } from "@playwright/test";
import { QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Navigate to a gallery page via hash and wait for it to render. */
async function goToGalleryPage(page: Page, hash: string) {
// First visit to let ha-gallery boot up
await page.goto(`/#${hash}`);
await page.waitForSelector("ha-gallery", { state: "attached" });
// Wait for the demo element to appear in ha-gallery's shadow root.
// The element name is derived from the hash: "components/ha-bar" → "demo-components-ha-bar".
// page-description is only rendered for pages that have a description field,
// so we cannot use it as a universal readiness signal.
const demoTag = `demo-${hash.replace("/", "-")}`;
await page.waitForFunction((tag) => {
const gallery = document.querySelector("ha-gallery") as any;
return gallery?.shadowRoot?.querySelector(tag) != null;
}, demoTag);
}
/** Assert a gallery page loads without console errors.
* Demo elements live inside ha-gallery's shadow root use >> to pierce it.
*/
async function assertPageLoads(page: Page, hash: string, selector: string) {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToGalleryPage(page, hash);
// Pierce ha-gallery's shadow root with >>
await expect(page.locator(`ha-gallery >> ${selector}`).first()).toBeAttached({
timeout: SHELL_TIMEOUT,
});
const realErrors = errors.filter(
(e) => !IGNORED_ERRORS.some((re) => re.test(e))
);
expect(
realErrors,
`JS errors on ${hash}: ${realErrors.join("; ")}`
).toHaveLength(0);
}
// Errors that are gallery-harness artifacts rather than bugs in the component
// under test. The Lit-context init-error family that used to live here is gone:
// ha-gallery now provides fallback contexts for every demo (mirroring the real
// app's root), so context-consuming components resolve `localize`, formatters,
// config, etc. synchronously instead of throwing during init.
const IGNORED_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
// from mock state data — pre-existing gallery data issue.
/Cannot read properties of undefined \(reading 'some'\)/,
];
// ---------------------------------------------------------------------------
// Gallery shell
// ---------------------------------------------------------------------------
test.describe("Gallery shell", () => {
test("page loads and ha-gallery mounts", async ({ page }) => {
const errors = trackPageErrors(page);
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await goToGalleryHome(page);
await page.goto("/");
await expect(page.locator("ha-gallery")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
expectNoPageErrors(errors, undefined, GALLERY_IGNORED_PAGE_ERRORS);
const realErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
);
expect(realErrors).toHaveLength(0);
});
test("sidebar renders navigation links", async ({ page }) => {
await goToGalleryHome(page);
await expect(galleryLocator(page, "ha-drawer")).toBeAttached({
await page.goto("/");
await page.waitForSelector("ha-gallery", { state: "attached" });
// The gallery drawer sidebar is inside ha-gallery's shadow root
await expect(page.locator("ha-gallery >> ha-drawer")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
});
defineGallerySmokeTests("Components", "components", componentPages);
defineGallerySmokeTests("More-info dialogs", "more-info", moreInfoPages);
defineGallerySmokeTests("Lovelace cards", "lovelace", lovelacePages);
// ---------------------------------------------------------------------------
// Component pages
// ---------------------------------------------------------------------------
const componentPages: { name: string; selector: string }[] = [
{ name: "ha-alert", selector: "demo-components-ha-alert" },
{ name: "ha-badge", selector: "demo-components-ha-badge" },
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",
selector: "demo-components-ha-control-circular-slider",
},
{
name: "ha-control-number-buttons",
selector: "demo-components-ha-control-number-buttons",
},
{
name: "ha-control-select-menu",
selector: "demo-components-ha-control-select-menu",
},
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
{
name: "ha-expansion-panel",
selector: "demo-components-ha-expansion-panel",
},
{ name: "ha-faded", selector: "demo-components-ha-faded" },
{ name: "ha-form", selector: "demo-components-ha-form" },
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
{
name: "ha-hs-color-picker",
selector: "demo-components-ha-hs-color-picker",
},
{ name: "ha-input", selector: "demo-components-ha-input" },
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
{ name: "ha-list", selector: "demo-components-ha-list" },
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
{
name: "ha-progress-button",
selector: "demo-components-ha-progress-button",
},
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
{ name: "ha-selector", selector: "demo-components-ha-selector" },
{ name: "ha-slider", selector: "demo-components-ha-slider" },
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
{ name: "ha-switch", selector: "demo-components-ha-switch" },
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
{ name: "ha-tip", selector: "demo-components-ha-tip" },
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
{
name: "ha-adaptive-dialog",
selector: "demo-components-ha-adaptive-dialog",
},
{
name: "ha-adaptive-popover",
selector: "demo-components-ha-adaptive-popover",
},
];
test.describe("Components", () => {
for (const { name, selector } of componentPages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `components/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// More-info pages
// ---------------------------------------------------------------------------
const moreInfoPages: { name: string; selector: string }[] = [
{ name: "light", selector: "demo-more-info-light" },
{ name: "climate", selector: "demo-more-info-climate" },
{ name: "cover", selector: "demo-more-info-cover" },
{ name: "fan", selector: "demo-more-info-fan" },
{ name: "humidifier", selector: "demo-more-info-humidifier" },
{ name: "input-number", selector: "demo-more-info-input-number" },
{ name: "input-text", selector: "demo-more-info-input-text" },
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
{ name: "lock", selector: "demo-more-info-lock" },
{ name: "media-player", selector: "demo-more-info-media-player" },
{ name: "number", selector: "demo-more-info-number" },
{ name: "scene", selector: "demo-more-info-scene" },
{ name: "timer", selector: "demo-more-info-timer" },
{ name: "update", selector: "demo-more-info-update" },
{ name: "vacuum", selector: "demo-more-info-vacuum" },
{ name: "water-heater", selector: "demo-more-info-water-heater" },
];
test.describe("More-info dialogs", () => {
for (const { name, selector } of moreInfoPages) {
test(`more-info ${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `more-info/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Lovelace card pages
// ---------------------------------------------------------------------------
const lovelacePages: { name: string; selector: string }[] = [
{ name: "area-card", selector: "demo-lovelace-area-card" },
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
{
name: "grid-and-stack-card",
selector: "demo-lovelace-grid-and-stack-card",
},
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
{ name: "light-card", selector: "demo-lovelace-light-card" },
{ name: "map-card", selector: "demo-lovelace-map-card" },
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
{
name: "picture-elements-card",
selector: "demo-lovelace-picture-elements-card",
},
{
name: "picture-entity-card",
selector: "demo-lovelace-picture-entity-card",
},
{
name: "picture-glance-card",
selector: "demo-lovelace-picture-glance-card",
},
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
];
test.describe("Lovelace cards", () => {
for (const { name, selector } of lovelacePages) {
test(`${name} renders without errors`, async ({ page }) => {
await assertPageLoads(page, `lovelace/${name}`, selector);
});
}
});
// ---------------------------------------------------------------------------
// Specific interaction tests
// ---------------------------------------------------------------------------
test.describe("Component interactions", () => {
test("ha-alert renders all four types", async ({ page }) => {
const demo = await getGalleryDemo(page, "components/ha-alert");
await goToGalleryPage(page, "components/ha-alert");
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// The demo uses property binding (.alertType) not attribute binding, so we
// verify that multiple ha-alert elements are present.
// The demo uses property binding (.alertType) not attribute binding,
// so we verify that multiple ha-alert elements are present.
const alerts = demo.locator("ha-alert");
await expect(alerts.nth(3)).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(alerts.first()).toBeAttached({ timeout: QUICK_TIMEOUT });
// There should be at least 4 alerts (one per type)
await expect(alerts)
.toHaveCount(4, { timeout: QUICK_TIMEOUT })
.catch(async () => {
// If not exactly 4, just verify there are some (demo may include more)
const count = await alerts.count();
expect(count).toBeGreaterThanOrEqual(4);
});
});
test("ha-button renders primary action button", async ({ page }) => {
const demo = await getGalleryDemo(page, "components/ha-button");
await expectGalleryDemoElement(demo, "ha-button, mwc-button");
await goToGalleryPage(page, "components/ha-button");
const demo = page.locator("ha-gallery >> demo-components-ha-button");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-control-slider can be found in DOM", async ({ page }) => {
const demo = await getGalleryDemo(page, "components/ha-control-slider");
await expectGalleryDemoElement(demo, "ha-control-slider");
await goToGalleryPage(page, "components/ha-control-slider");
const demo = page.locator(
"ha-gallery >> demo-components-ha-control-slider"
);
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-form renders schema-driven fields", async ({ page }) => {
const demo = await getGalleryDemo(page, "components/ha-form");
await goToGalleryPage(page, "components/ha-form");
const demo = page.locator("ha-gallery >> demo-components-ha-form");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("ha-form").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
await expectGalleryDemoElement(demo, "ha-form");
test("ha-dialog demo renders a dialog trigger", async ({ page }) => {
await goToGalleryPage(page, "components/ha-dialog");
const demo = page.locator("ha-gallery >> demo-components-ha-dialog");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
});
test("tile-card renders entity state", async ({ page }) => {
const demo = await getGalleryDemo(page, "lovelace/tile-card");
await expectGalleryDemoElement(demo, "hui-tile-card");
await goToGalleryPage(page, "lovelace/tile-card");
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("more-info light renders controls", async ({ page }) => {
const demo = await getGalleryDemo(page, "more-info/light");
await goToGalleryPage(page, "more-info/light");
const demo = page.locator("ha-gallery >> demo-more-info-light");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// Light more-info should contain a brightness or color-temp control
await expect(
demo
.locator("ha-control-slider, ha-more-info-light, more-info-content")
.first()
).toBeAttached({ timeout: SHELL_TIMEOUT });
});
await expectGalleryDemoElement(
demo,
"ha-control-slider, ha-more-info-light, more-info-content",
SHELL_TIMEOUT
);
test("more-info cover renders position controls", async ({ page }) => {
await goToGalleryPage(page, "more-info/cover");
const demo = page.locator("ha-gallery >> demo-more-info-cover");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
});
test("ha-gauge renders a gauge element", async ({ page }) => {
await getGalleryDemo(page, "components/ha-gauge");
// ha-gauge page is markdown-based; gauge elements render in the description area.
await expect(galleryLocator(page, "ha-gauge").first()).toBeAttached({
await goToGalleryPage(page, "components/ha-gauge");
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// ha-gauge page is markdown-based; gauge elements render in the description area
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
timeout: QUICK_TIMEOUT,
});
});
test("ha-switch toggles state on click", async ({ page }) => {
const demo = await getGalleryDemo(page, "components/ha-switch");
await goToGalleryPage(page, "components/ha-switch");
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
await expect(demo).toBeAttached({ timeout: SHELL_TIMEOUT });
// Find the first interactive (non-disabled) switch. Pull its checked state
// from the property because ha-switch toggles via property, not attribute.
// from the property ha-switch toggles via property, not the attribute.
const switchEl = demo.locator("ha-switch:not([disabled])").first();
await expect(switchEl).toBeAttached({ timeout: QUICK_TIMEOUT });
const before = await switchEl.evaluate(
(el: HTMLElement & { checked?: boolean }) => el.checked === true
);
const before = await switchEl.evaluate((el: any) => el.checked === true);
await switchEl.click();
await expect
.poll(
() =>
switchEl.evaluate(
(el: HTMLElement & { checked?: boolean }) => el.checked === true
),
{ timeout: QUICK_TIMEOUT }
)
.poll(() => switchEl.evaluate((el: any) => el.checked === true), {
timeout: QUICK_TIMEOUT,
})
.toBe(!before);
});
});
-94
View File
@@ -1,94 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import {
defineParallelSmokeTests,
expectNoPageErrors,
QUICK_TIMEOUT,
SHELL_TIMEOUT,
trackPageErrors,
} from "../helpers";
export const GALLERY_IGNORED_PAGE_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
// from mock state data - pre-existing gallery data issue.
/Cannot read properties of undefined \(reading 'some'\)/,
];
export interface GalleryPageSmokeCase {
name: string;
selector: string;
}
export const galleryLocator = (page: Page, selector: string) =>
page.locator(`ha-gallery >> ${selector}`);
const galleryDemoTag = (hash: string) => `demo-${hash.replace(/\//g, "-")}`;
async function waitForGalleryReady(page: Page) {
await expect(page.locator("ha-gallery")).toBeAttached({
timeout: SHELL_TIMEOUT,
});
}
export async function goToGalleryHome(page: Page) {
await page.goto("/");
await waitForGalleryReady(page);
}
export async function goToGalleryPage(page: Page, hash: string) {
await page.goto(`/#${hash}`);
}
async function expectGalleryPageSelector(page: Page, selector: string) {
const locator = galleryLocator(page, selector).first();
await expect(locator).toBeAttached({ timeout: SHELL_TIMEOUT });
return locator;
}
export async function getGalleryDemo(page: Page, hash: string) {
await goToGalleryPage(page, hash);
return expectGalleryPageSelector(page, galleryDemoTag(hash));
}
export async function assertGalleryPageLoads(
page: Page,
hash: string,
selector: string
) {
const errors = trackPageErrors(page);
await goToGalleryPage(page, hash);
await expectGalleryPageSelector(page, selector);
expectNoPageErrors(errors, hash, GALLERY_IGNORED_PAGE_ERRORS);
}
export function defineGallerySmokeTests(
groupName: string,
routePrefix: string,
pages: GalleryPageSmokeCase[]
) {
defineParallelSmokeTests({
groups: [{ name: groupName, routePrefix, pages }],
groupName: (group) => group.name,
cases: (group) => group.pages,
testName: (smokeCase) => `${smokeCase.name} renders without errors`,
run: async ({ page, group, smokeCase }) => {
await assertGalleryPageLoads(
page,
`${group.routePrefix}/${smokeCase.name}`,
smokeCase.selector
);
},
});
}
export async function expectGalleryDemoElement(
demo: Locator,
selector: string,
timeout = QUICK_TIMEOUT
) {
await expect(demo.locator(selector).first()).toBeAttached({ timeout });
}
-117
View File
@@ -1,117 +0,0 @@
import type { GalleryPageSmokeCase } from "./helpers";
export const componentPages: GalleryPageSmokeCase[] = [
{ name: "ha-alert", selector: "demo-components-ha-alert" },
{ name: "ha-badge", selector: "demo-components-ha-badge" },
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",
selector: "demo-components-ha-control-circular-slider",
},
{
name: "ha-control-number-buttons",
selector: "demo-components-ha-control-number-buttons",
},
{
name: "ha-control-select-menu",
selector: "demo-components-ha-control-select-menu",
},
{ name: "ha-control-select", selector: "demo-components-ha-control-select" },
{ name: "ha-control-slider", selector: "demo-components-ha-control-slider" },
{ name: "ha-control-switch", selector: "demo-components-ha-control-switch" },
{ name: "ha-dialog", selector: "demo-components-ha-dialog" },
{ name: "ha-dropdown", selector: "demo-components-ha-dropdown" },
{
name: "ha-expansion-panel",
selector: "demo-components-ha-expansion-panel",
},
{ name: "ha-faded", selector: "demo-components-ha-faded" },
{ name: "ha-form", selector: "demo-components-ha-form" },
{ name: "ha-gauge", selector: "demo-components-ha-gauge" },
{
name: "ha-hs-color-picker",
selector: "demo-components-ha-hs-color-picker",
},
{ name: "ha-input", selector: "demo-components-ha-input" },
{ name: "ha-label-badge", selector: "demo-components-ha-label-badge" },
{ name: "ha-list", selector: "demo-components-ha-list" },
{ name: "ha-marquee-text", selector: "demo-components-ha-marquee-text" },
{
name: "ha-progress-button",
selector: "demo-components-ha-progress-button",
},
{ name: "ha-select-box", selector: "demo-components-ha-select-box" },
{ name: "ha-selector", selector: "demo-components-ha-selector" },
{ name: "ha-slider", selector: "demo-components-ha-slider" },
{ name: "ha-spinner", selector: "demo-components-ha-spinner" },
{ name: "ha-switch", selector: "demo-components-ha-switch" },
{ name: "ha-textarea", selector: "demo-components-ha-textarea" },
{ name: "ha-tip", selector: "demo-components-ha-tip" },
{ name: "ha-tooltip", selector: "demo-components-ha-tooltip" },
{
name: "ha-adaptive-dialog",
selector: "demo-components-ha-adaptive-dialog",
},
{
name: "ha-adaptive-popover",
selector: "demo-components-ha-adaptive-popover",
},
];
export const moreInfoPages: GalleryPageSmokeCase[] = [
{ name: "light", selector: "demo-more-info-light" },
{ name: "climate", selector: "demo-more-info-climate" },
{ name: "cover", selector: "demo-more-info-cover" },
{ name: "fan", selector: "demo-more-info-fan" },
{ name: "humidifier", selector: "demo-more-info-humidifier" },
{ name: "input-number", selector: "demo-more-info-input-number" },
{ name: "input-text", selector: "demo-more-info-input-text" },
{ name: "lawn-mower", selector: "demo-more-info-lawn-mower" },
{ name: "lock", selector: "demo-more-info-lock" },
{ name: "media-player", selector: "demo-more-info-media-player" },
{ name: "number", selector: "demo-more-info-number" },
{ name: "scene", selector: "demo-more-info-scene" },
{ name: "timer", selector: "demo-more-info-timer" },
{ name: "update", selector: "demo-more-info-update" },
{ name: "vacuum", selector: "demo-more-info-vacuum" },
{ name: "water-heater", selector: "demo-more-info-water-heater" },
];
export const lovelacePages: GalleryPageSmokeCase[] = [
{ name: "area-card", selector: "demo-lovelace-area-card" },
{ name: "conditional-card", selector: "demo-lovelace-conditional-card" },
{ name: "entities-card", selector: "demo-lovelace-entities-card" },
{ name: "entity-button-card", selector: "demo-lovelace-entity-button-card" },
{ name: "entity-filter-card", selector: "demo-lovelace-entity-filter-card" },
{ name: "gauge-card", selector: "demo-lovelace-gauge-card" },
{ name: "glance-card", selector: "demo-lovelace-glance-card" },
{
name: "grid-and-stack-card",
selector: "demo-lovelace-grid-and-stack-card",
},
{ name: "iframe-card", selector: "demo-lovelace-iframe-card" },
{ name: "light-card", selector: "demo-lovelace-light-card" },
{ name: "map-card", selector: "demo-lovelace-map-card" },
{ name: "markdown-card", selector: "demo-lovelace-markdown-card" },
{ name: "media-control-card", selector: "demo-lovelace-media-control-card" },
{ name: "media-player-row", selector: "demo-lovelace-media-player-row" },
{ name: "picture-card", selector: "demo-lovelace-picture-card" },
{
name: "picture-elements-card",
selector: "demo-lovelace-picture-elements-card",
},
{
name: "picture-entity-card",
selector: "demo-lovelace-picture-entity-card",
},
{
name: "picture-glance-card",
selector: "demo-lovelace-picture-glance-card",
},
{ name: "thermostat-card", selector: "demo-lovelace-thermostat-card" },
{ name: "tile-card", selector: "demo-lovelace-tile-card" },
{ name: "todo-list-card", selector: "demo-lovelace-todo-list-card" },
];
+17 -70
View File
@@ -1,7 +1,6 @@
/**
* Shared helpers and constants for Playwright e2e suites.
*/
import { expect, test, type Page, type TestInfo } from "@playwright/test";
// ── Timeouts ────────────────────────────────────────────────────────────────
// Centralised so tweaks don't require search-and-replace across spec files.
@@ -18,73 +17,21 @@ export const NAVIGATION_TIMEOUT = 30_000;
// ── Error filtering ─────────────────────────────────────────────────────────
type PageError = { message: string } | string;
export const IGNORED_PAGE_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
];
export function trackPageErrors(page: Page) {
const errors: PageError[] = [];
page.on("pageerror", (error) => errors.push(error));
return errors;
}
export function pageErrors(
errors: PageError[],
ignoredErrors = IGNORED_PAGE_ERRORS
) {
return errors
.map((error) => (typeof error === "string" ? error : error.message))
.filter((message) =>
ignoredErrors.every((pattern) => !pattern.test(message))
);
}
export function expectNoPageErrors(
errors: PageError[],
context?: string,
ignoredErrors = IGNORED_PAGE_ERRORS
) {
const realErrors = pageErrors(errors, ignoredErrors);
const details = realErrors.length ? `: ${realErrors.join("; ")}` : "";
expect(
realErrors,
context ? `JS errors on ${context}${details}` : `JS errors${details}`
).toHaveLength(0);
}
export interface DefineParallelSmokeTestsOptions<TGroup, TCase> {
groups: readonly TGroup[];
groupName: (group: TGroup) => string;
cases: (group: TGroup) => readonly TCase[];
testName: (smokeCase: TCase, group: TGroup) => string;
run: (context: {
page: Page;
testInfo: TestInfo;
group: TGroup;
smokeCase: TCase;
}) => Promise<void>;
}
export function defineParallelSmokeTests<TGroup, TCase>({
groups,
groupName,
cases,
testName,
run,
}: DefineParallelSmokeTestsOptions<TGroup, TCase>) {
for (const group of groups) {
test.describe(groupName(group), () => {
test.describe.configure({ mode: "parallel" });
for (const smokeCase of cases(group)) {
test(testName(smokeCase, group), async ({ page }, testInfo) => {
await run({ page, testInfo, group, smokeCase });
});
}
});
}
/**
* Filter out errors known to be unrelated to the app under test:
* - ResizeObserver loop notifications (browser quirk, harmless)
* - Non-Error rejections (mock data throws plain objects)
* - Browser extension noise
*/
export function appErrors(errors: { message: string }[] | string[]) {
const messages =
typeof errors[0] === "string"
? (errors as string[])
: (errors as { message: string }[]).map((e) => e.message);
return messages.filter(
(msg) =>
!msg.includes("ResizeObserver") &&
!msg.includes("Non-Error") &&
!msg.includes("Extension context")
);
}
-14
View File
@@ -1,14 +0,0 @@
const DEFAULT_LOCAL_WORKERS = "60%";
export const getE2EWorkers = (): number | string => {
if (process.env.CI) {
return 1;
}
const workers = process.env.E2E_WORKERS;
if (!workers) {
return DEFAULT_LOCAL_WORKERS;
}
return /^[1-9]\d*$/.test(workers) ? Number(workers) : workers;
};
+1 -4
View File
@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
const APP_PORT = 8095;
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
@@ -12,10 +11,8 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results/app",
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
use: {
+1 -4
View File
@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
// Port 8090 matches the `develop_demo` dev server (rspack-dev-server-demo).
// This means running `demo/script/develop_demo` and then `yarn test:e2e:local`
@@ -17,10 +16,8 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results/demo",
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
use: {
+1 -4
View File
@@ -1,5 +1,4 @@
import { defineConfig, devices } from "@playwright/test";
import { getE2EWorkers } from "./playwright-workers";
const GALLERY_PORT = 8100;
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
@@ -12,10 +11,8 @@ export default defineConfig({
expect: { timeout: 15_000 },
retries: process.env.CI ? 1 : 0,
fullyParallel: true,
workers: getE2EWorkers(),
outputDir: "test-results/gallery",
outputDir: "test-results",
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
use: {
+26 -122
View File
@@ -1,82 +1,16 @@
#!/usr/bin/env node
// Runs each e2e suite (demo, app, gallery) regardless of individual failures,
// then collects and merges blob reports locally and exits with a non-zero code
// if any suite failed.
// then collects and merges blob reports and exits with a non-zero code if any
// suite failed.
//
// Usage: node test/e2e/run-suites.mjs <suite> [<suite> ...]
// Where <suite> matches a test:e2e:<suite> script in package.json,
// e.g. "demo", "app", "gallery".
//
// Running suites independently avoids the && short-circuit problem where a
// failing suite skips the remaining suites and their blob reports.
// Set E2E_WORKERS to a number or percentage to override local workers.
// Using ; or running suites independently avoids the && short-circuit problem
// where a failing suite skips the remaining suites and their blob reports.
import { execFileSync, spawn } from "child_process";
const TRUE_VALUES = new Set(["1", "true", "yes"]);
const isTruthy = (value) => TRUE_VALUES.has(value?.toLowerCase() ?? "");
const formatDuration = (ms) => {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`;
};
const writePrefixed = (suite, stream, chunk, pending) => {
const lines = `${pending.value}${chunk}`.split(/\r?\n/);
pending.value = lines.pop() ?? "";
for (const line of lines) {
stream.write(`[${suite}] ${line}\n`);
}
};
const flushPrefixed = (suite, stream, pending) => {
if (!pending.value) return;
stream.write(`[${suite}] ${pending.value}\n`);
pending.value = "";
};
const runSuite = (suite, env) =>
new Promise((resolve) => {
const started = Date.now();
const child = spawn("yarn", [`test:e2e:${suite}`], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
const pendingStdout = { value: "" };
const pendingStderr = { value: "" };
const workerLabel = env.E2E_WORKERS ? ` (workers: ${env.E2E_WORKERS})` : "";
process.stdout.write(
`\n--- Running suite: test:e2e:${suite}${workerLabel} ---\n`
);
child.stdout.on("data", (chunk) =>
writePrefixed(suite, process.stdout, chunk, pendingStdout)
);
child.stderr.on("data", (chunk) =>
writePrefixed(suite, process.stderr, chunk, pendingStderr)
);
child.on("error", (err) => {
flushPrefixed(suite, process.stdout, pendingStdout);
flushPrefixed(suite, process.stderr, pendingStderr);
process.stderr.write(`[${suite}] Failed to start: ${err.message}\n`);
resolve({ suite, code: 1, duration: Date.now() - started });
});
child.on("close", (code) => {
flushPrefixed(suite, process.stdout, pendingStdout);
flushPrefixed(suite, process.stderr, pendingStderr);
const duration = Date.now() - started;
process.stdout.write(
`--- Finished suite: test:e2e:${suite} (${formatDuration(duration)}) ---\n`
);
resolve({ suite, code: code ?? 1, duration });
});
});
import { execFileSync } from "child_process";
const suites = process.argv.slice(2);
if (!suites.length) {
@@ -84,62 +18,32 @@ if (!suites.length) {
process.exit(1);
}
const sequential = isTruthy(process.env.E2E_SEQUENTIAL);
const skipMerge = isTruthy(process.env.E2E_SKIP_MERGE);
const suiteWorkers =
!sequential &&
!process.env.CI &&
!process.env.E2E_WORKERS &&
suites.length > 1
? `${Math.max(1, Math.floor(60 / suites.length))}%`
: undefined;
const suiteEnv = suiteWorkers
? { ...process.env, E2E_WORKERS: suiteWorkers }
: process.env;
const failures = [];
const results = [];
if (sequential) {
for (const suite of suites) {
// eslint-disable-next-line no-await-in-loop
results.push(await runSuite(suite, suiteEnv));
for (const suite of suites) {
process.stdout.write(`\n--- Running suite: test:e2e:${suite} ---\n`);
try {
execFileSync("yarn", [`test:e2e:${suite}`], { stdio: "inherit" });
} catch {
failures.push(suite);
}
} else {
results.push(
...(await Promise.all(suites.map((suite) => runSuite(suite, suiteEnv))))
);
}
const failures = results
.filter(({ code }) => code !== 0)
.map(({ suite }) => suite);
process.stdout.write("\nE2E suite timings:\n");
for (const { suite, duration } of results) {
process.stdout.write(`- test:e2e:${suite}: ${formatDuration(duration)}\n`);
}
// Collect and merge blob reports regardless of suite outcomes.
if (skipMerge) {
process.stdout.write(
"\nSkipping merged e2e report because E2E_SKIP_MERGE is set.\n"
);
} else {
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
}
execFileSync("node", ["test/e2e/collect-blob-reports.mjs"], {
stdio: "inherit",
});
execFileSync(
"npx",
[
"playwright",
"merge-reports",
"-c",
"test/e2e/playwright.merge.config.ts",
"test/e2e/reports/blob",
],
{ stdio: "inherit" }
);
if (failures.length) {
process.stderr.write(
+66 -66
View File
@@ -2454,15 +2454,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.43.5, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.5
resolution: "@codemirror/view@npm:6.43.5"
"@codemirror/view@npm:6.43.6, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.6
resolution: "@codemirror/view@npm:6.43.6"
dependencies:
"@codemirror/state": "npm:^6.7.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/24e28f57d84d1db5c140f704c1af8591c279099d26f864a12a16cdd8a372a10048327ea81d475da57d0e84ab9ac49ad71209d54aa1211dd81d79a144e5b6039c
checksum: 10/be058e86523d5770921c6bad7963eb5ef0a7db340922db93043394e1e93d1f3125611a3c2dbf143f1ab69b426038cd2034ffa7484d4b55d1ac909f2ceed2ee06
languageName: node
linkType: hard
@@ -6142,12 +6142,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/coverage-v8@npm:4.1.9"
"@vitest/coverage-v8@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.9"
"@vitest/utils": "npm:4.1.10"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -6157,34 +6157,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.9
vitest: 4.1.9
"@vitest/browser": 4.1.10
vitest: 4.1.10
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/1f236e17336973868aa6e7662b863b1c519d07840107daab3465429652741fc1f8a8988d1b7b28b8cfa883c7280f7479927a85e56c7874a0ddc5cc8ceda25cd6
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/expect@npm:4.1.9"
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.9"
"@vitest/utils": "npm:4.1.9"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/aba1a06cd28199f9c861d97797b014c0584fa6f6197e78345da0db5f74914d47f18958bb848658e889ca44452aa61e07ae851c16ea7b2175afd50d649dd4ed8c
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/mocker@npm:4.1.9"
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
dependencies:
"@vitest/spy": "npm:4.1.9"
"@vitest/spy": "npm:4.1.10"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -6195,56 +6195,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/3e35ff3e2ecbdfbcae598e9c5c83978dd5f0cf3b16df37cf947c80faabce797ab275ca2075c3bb8ca85f595f3070267f93cb6798bbe415f1af2698f51833974c
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/pretty-format@npm:4.1.9"
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/52512b300c000594c54bebbbfe31fab39e416a35d3686e2c46bc8e48ef8476d32306605f7736139608c3962943e0d22790dc15a3e6b1ffa436143d31f743a7c8
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/runner@npm:4.1.9"
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
dependencies:
"@vitest/utils": "npm:4.1.9"
"@vitest/utils": "npm:4.1.10"
pathe: "npm:^2.0.3"
checksum: 10/52e4e16e627faa62676f17683e570f505d58d2ce0ef421a3ae60e70c0ec5606d4af090fa6c7d5717d6e949f4401d6357b1f69cf06e52a5455a0ad9c9040268c0
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/snapshot@npm:4.1.9"
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.9"
"@vitest/utils": "npm:4.1.9"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/c83349b1ad08d48284c1d3393168a7b7faffd24ace1ef337751a568dad322d83b0f9bc29378a4a60379cf2a13a268092b1d802936d6adb1ca28859f02dad8b87
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/spy@npm:4.1.9"
checksum: 10/8b8e42cc8e4b20d29bd8b312f34b9dbf2e20d4b4cdc24e3bcf6fd4d3b1f49e8924636d2730cca3946fbb45de893dfb531c77b832eb853c2624fdc2b800444e75
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.9":
version: 4.1.9
resolution: "@vitest/utils@npm:4.1.9"
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
dependencies:
"@vitest/pretty-format": "npm:4.1.9"
"@vitest/pretty-format": "npm:4.1.10"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/78f5969fc09b1a95fda9dadd37e84a3a6ead35f66af15ad3b792eef35f80407047803e7afd53df86a8d794f59bf25ffbdc4146099140a3d5f9b51ea061bf2308
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
languageName: node
linkType: hard
@@ -9732,7 +9732,7 @@ __metadata:
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.1"
"@codemirror/state": "npm:6.7.1"
"@codemirror/view": "npm:6.43.5"
"@codemirror/view": "npm:6.43.6"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
@@ -9793,7 +9793,7 @@ __metadata:
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.9"
"@vitest/coverage-v8": "npm:4.1.10"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
@@ -9879,7 +9879,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.62.1"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.9"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -15367,17 +15367,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.9":
version: 4.1.9
resolution: "vitest@npm:4.1.9"
"vitest@npm:4.1.10":
version: 4.1.10
resolution: "vitest@npm:4.1.10"
dependencies:
"@vitest/expect": "npm:4.1.9"
"@vitest/mocker": "npm:4.1.9"
"@vitest/pretty-format": "npm:4.1.9"
"@vitest/runner": "npm:4.1.9"
"@vitest/snapshot": "npm:4.1.9"
"@vitest/spy": "npm:4.1.9"
"@vitest/utils": "npm:4.1.9"
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -15395,12 +15395,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.9
"@vitest/browser-preview": 4.1.9
"@vitest/browser-webdriverio": 4.1.9
"@vitest/coverage-istanbul": 4.1.9
"@vitest/coverage-v8": 4.1.9
"@vitest/ui": 4.1.9
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -15431,7 +15431,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/64f9d1a0aae92c493c39822ecae8ec5b5a336fc27166f776d08c01ae79ef1ec5485a1826ef1451bb05df2edaae109894125c2ecceadaa56c17be2690f66f9758
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
languageName: node
linkType: hard