mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-09 10:53:24 +00:00
Compare commits
6 Commits
fix-3383
...
e2e-playwr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32f5b6a50 | ||
|
|
a21cf5d995 | ||
|
|
9aa6cd4154 | ||
|
|
2024ce0aef | ||
|
|
908a518f18 | ||
|
|
d169eb9c49 |
296
.github/workflows/e2e.yaml
vendored
Normal file
296
.github/workflows/e2e.yaml
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── 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
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e:local
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
|
||||
e2e-browserstack:
|
||||
name: E2E (BrowserStack)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
environment: browserstack
|
||||
env:
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
# Unique identifier so BrowserStack Local tunnels from parallel CI runs
|
||||
# don't collide with each other.
|
||||
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
# Start BrowserStack Local tunnel so BrowserStack's cloud browsers can
|
||||
# reach the tests running on localhost.
|
||||
- name: Start BrowserStack Local tunnel
|
||||
uses: browserstack/github-actions/setup-local@93aebce225b754566349151c0676b26b005e591b # v1.0.4
|
||||
with:
|
||||
local-testing: start
|
||||
local-identifier: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Run Playwright tests (BrowserStack)
|
||||
run: yarn test:e2e:browserstack
|
||||
env:
|
||||
BROWSERSTACK: "true"
|
||||
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Stop BrowserStack Local tunnel
|
||||
uses: browserstack/github-actions/setup-local@93aebce225b754566349151c0676b26b005e591b # v1.0.4
|
||||
if: always()
|
||||
with:
|
||||
local-testing: stop
|
||||
local-identifier: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-browserstack
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge reports and upload ──────────────────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local, e2e-browserstack]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Download blob report (BrowserStack)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-browserstack
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
- name: Merge blob reports
|
||||
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global require, module, __dirname, process */
|
||||
const path = require("path");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
@@ -321,4 +320,22 @@ module.exports.config = {
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module */
|
||||
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||
// The original helper evaluates `Function('return require("...")')()`
|
||||
// when it detects a Node environment, which causes a runtime
|
||||
|
||||
@@ -45,3 +45,10 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
41
build-scripts/gulp/e2e-test-app.js
Normal file
41
build-scripts/gulp/e2e-test-app.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
gulp.task(
|
||||
"develop-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-dev-server-e2e-test-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app",
|
||||
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",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
@@ -266,3 +266,24 @@ gulp.task(
|
||||
paths.landingPage_output_es5
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
genPagesDevTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
genPagesProdTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root,
|
||||
paths.e2eTestApp_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
// Copy app static files (icons, polyfills, etc.)
|
||||
fs.copySync(
|
||||
polyPath("public/static"),
|
||||
path.resolve(paths.e2eTestApp_output_root, "static")
|
||||
);
|
||||
// Copy e2e test app public files (manifest, sw stubs)
|
||||
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
|
||||
if (fs.existsSync(e2ePublic)) {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
copyMdiIcons(paths.e2eTestApp_output_static);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
prodBuild(
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -50,4 +50,15 @@ module.exports = {
|
||||
),
|
||||
|
||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
||||
|
||||
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
|
||||
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
|
||||
e2eTestApp_output_static: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/static"
|
||||
),
|
||||
e2eTestApp_output_latest: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/frontend_latest"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global require, module, __dirname */
|
||||
const { existsSync } = require("fs");
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
createAppConfig,
|
||||
createDemoConfig,
|
||||
@@ -345,4 +349,5 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
21
demo/src/stubs/assist.ts
Normal file
21
demo/src/stubs/assist.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
// the UI does not hang waiting for a response.
|
||||
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
type: "run-end",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
21
demo/src/stubs/cloud.ts
Normal file
21
demo/src/stubs/cloud.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
// REST mock for cloud status — returns disconnected so config panel loads
|
||||
// without errors but without requiring cloud integration.
|
||||
hass.mockAPI("cloud/status", () => ({
|
||||
logged_in: false,
|
||||
cloud: "disconnected",
|
||||
prefs: {
|
||||
google_enabled: false,
|
||||
alexa_enabled: false,
|
||||
cloudhooks: {},
|
||||
remote_enabled: false,
|
||||
},
|
||||
google_registered: false,
|
||||
alexa_registered: false,
|
||||
remote_domain: null,
|
||||
remote_connected: false,
|
||||
remote_certificate: null,
|
||||
}));
|
||||
};
|
||||
5
demo/src/stubs/update.ts
Normal file
5
demo/src/stubs/update.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,6 +228,12 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
16
package.json
16
package.json
@@ -20,7 +20,20 @@
|
||||
"prepack": "pinst --disable",
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"test:e2e": "yarn test:e2e:demo && yarn test:e2e:app && yarn test:e2e:gallery && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"test:e2e:local": "yarn test:e2e:demo:local && yarn test:e2e:app:local && yarn test:e2e:gallery:local && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"test:e2e:browserstack": "yarn test:e2e:demo:browserstack && yarn test:e2e:app:browserstack && yarn test:e2e:gallery:browserstack && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:demo:local": "playwright test --config test/e2e/playwright.demo.config.ts --project=local",
|
||||
"test:e2e:demo:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:app:local": "playwright test --config test/e2e/playwright.app.config.ts --project=local",
|
||||
"test:e2e:app:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
|
||||
"test:e2e:gallery:local": "playwright test --config test/e2e/playwright.gallery.config.ts --project=local",
|
||||
"test:e2e:gallery:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.gallery.config.ts"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
@@ -141,6 +154,7 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.1",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
|
||||
* with the displayed content.
|
||||
*/
|
||||
export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
@@ -11,7 +11,6 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -411,7 +410,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
@@ -257,7 +256,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,6 @@ import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
@@ -399,7 +398,8 @@ export class StatisticsChart extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -412,6 +412,10 @@ export const provideHass = (
|
||||
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
},
|
||||
],
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
...overrideData,
|
||||
};
|
||||
|
||||
|
||||
@@ -97,10 +97,10 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
|
||||
|
||||
if (this._observedBadges) {
|
||||
this._resizeObserver.observe(this._observedBadges);
|
||||
} else {
|
||||
this._badgesOverflowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
this._measureBadgesOverflow();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TOOLTIP_GAP_PX,
|
||||
TOOLTIP_TOP_OFFSET_PX,
|
||||
sideTooltipPosition,
|
||||
} from "../../../src/components/chart/chart-tooltip-position";
|
||||
|
||||
const callPosition = (
|
||||
cursorX: number,
|
||||
options: {
|
||||
viewSize?: [number, number];
|
||||
contentSize?: [number, number];
|
||||
rtl?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const dom = document.createElement("div");
|
||||
if (options.rtl) {
|
||||
dom.setAttribute("dir", "rtl");
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
const result = sideTooltipPosition([cursorX, 0], [], dom, null, {
|
||||
viewSize: options.viewSize ?? [800, 400],
|
||||
contentSize: options.contentSize ?? [200, 120],
|
||||
}) as [number, number];
|
||||
if (options.rtl) {
|
||||
document.body.removeChild(dom);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
describe("sideTooltipPosition", () => {
|
||||
it("places tooltip to the right of the cursor by default", () => {
|
||||
const [x, y] = callPosition(100);
|
||||
expect(x).toBe(100 + TOOLTIP_GAP_PX);
|
||||
expect(y).toBe(TOOLTIP_TOP_OFFSET_PX);
|
||||
});
|
||||
|
||||
it("flips to the left when right side overflows the chart", () => {
|
||||
const [x] = callPosition(700, {
|
||||
viewSize: [800, 400],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(x).toBe(700 - TOOLTIP_GAP_PX - 200);
|
||||
});
|
||||
|
||||
it("clamps to chart bounds when neither side fits", () => {
|
||||
const [x] = callPosition(50, {
|
||||
viewSize: [120, 400],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(x).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps Y when chart is shorter than the tooltip", () => {
|
||||
const [, y] = callPosition(100, {
|
||||
viewSize: [800, 100],
|
||||
contentSize: [200, 120],
|
||||
});
|
||||
expect(y).toBe(0);
|
||||
});
|
||||
|
||||
it("prefers the left of the cursor in RTL mode", () => {
|
||||
const [x] = callPosition(400, { rtl: true });
|
||||
expect(x).toBe(400 - TOOLTIP_GAP_PX - 200);
|
||||
});
|
||||
});
|
||||
254
test/e2e/app.spec.ts
Normal file
254
test/e2e/app.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* E2E tests for the HA test app (port 8095).
|
||||
*
|
||||
* Run with:
|
||||
* yarn test:e2e:app:local
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("App shell", () => {
|
||||
test("page loads and ha-test element mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await goToPanel(page, "/");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders with expected panels", async ({ page }) => {
|
||||
await goToPanel(page, "/");
|
||||
|
||||
// Regular panels use #sidebar-panel-{urlPath} inside ha-sidebar's shadow root
|
||||
for (const urlPath of ["lovelace", "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("admin user sees config panel in sidebar", async ({ page }) => {
|
||||
await goToPanel(page, "/");
|
||||
await expect(
|
||||
page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
)
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test("non-admin user does NOT see config panel in sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToPanel(page, "/?scenario=non-admin");
|
||||
// Config panel is adminOnly — should not appear for non-admin
|
||||
const configLink = page.locator(
|
||||
`ha-test >> home-assistant-main >> ha-sidebar >> #sidebar-config`
|
||||
);
|
||||
await expect(configLink).not.toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Panel navigation", () => {
|
||||
test("navigates to lovelace dashboard", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
await expect(
|
||||
page.locator("ha-panel-lovelace, hui-root").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to energy panel", async ({ page }) => {
|
||||
await goToPanel(page, "/energy");
|
||||
await expect(
|
||||
page.locator("ha-panel-energy, energy-view").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to history panel", async ({ page }) => {
|
||||
await goToPanel(page, "/history");
|
||||
await expect(
|
||||
page.locator("ha-panel-history, history-panel").first()
|
||||
).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to developer-tools panel", async ({ page }) => {
|
||||
// Since 2026.2 developer-tools is part of the config panel
|
||||
await goToPanel(page, "/config/developer-tools");
|
||||
await expect(
|
||||
page.locator("ha-panel-config, developer-tools-main").first()
|
||||
).toBeAttached({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test("navigates to profile panel", async ({ page }) => {
|
||||
await goToPanel(page, "/profile");
|
||||
await expect(
|
||||
page.locator("ha-panel-profile, ha-config-user-profile").first()
|
||||
).toBeAttached({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lovelace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Lovelace dashboard", () => {
|
||||
test("renders cards", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// At least one card should appear
|
||||
await expect(page.locator("hui-card, hui-tile-card").first()).toBeAttached({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin user sees edit button", async ({ page }) => {
|
||||
await goToPanel(page, "/lovelace");
|
||||
// The edit FAB / menu button is present for admins
|
||||
await expect(
|
||||
page.locator("[data-testid='edit-mode-button'], ha-menu-button")
|
||||
).toBeAttached({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info dialog (light)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Light more-info dialog", () => {
|
||||
test("opens more-info dialog for a light entity", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=light-more-info");
|
||||
|
||||
// Wait for ha-test to be ready
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
|
||||
// Navigate to lovelace where tiles should appear
|
||||
await page.goto("/#/lovelace");
|
||||
await page.waitForFunction(() => Boolean((window as any).__mockHass));
|
||||
|
||||
// Trigger more-info for our known test entity via JS
|
||||
await page.evaluate(() => {
|
||||
const hass = (window as any).__mockHass;
|
||||
// Build the path dynamically to prevent TypeScript from resolving it
|
||||
// as a local module (it is a runtime URL served by the test app).
|
||||
const dialogPath = ["/frontend_latest", "ha-more-info-dialog.js"].join(
|
||||
"/"
|
||||
);
|
||||
hass.mockEvent("show-dialog", {
|
||||
dialogTag: "ha-more-info-dialog",
|
||||
dialogImport: () =>
|
||||
import(/* @vite-ignore */ dialogPath).catch(() => null),
|
||||
dialogParams: { entityId: "light.test_light" },
|
||||
});
|
||||
// Use the built-in fire event mechanism
|
||||
const el = document.querySelector("ha-test") as any;
|
||||
if (el) {
|
||||
const event = new CustomEvent("hass-more-info", {
|
||||
detail: { entityId: "light.test_light" },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
el.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// The more-info dialog should appear
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
await expect(dialog).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Theming", () => {
|
||||
test("dark theme sets darkMode flag", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=dark-theme");
|
||||
|
||||
await expect(page.locator("ha-test")).toBeAttached();
|
||||
|
||||
// The dark-theme scenario sets selectedTheme.dark = true, which causes
|
||||
// _applyTheme() to set themes.darkMode = true on the element.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
|
||||
true,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("custom theme applies CSS variables", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=custom-theme");
|
||||
|
||||
// The custom-theme scenario sets --primary-color to #e91e63
|
||||
const primaryColor = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--primary-color"
|
||||
)
|
||||
);
|
||||
expect(primaryColor.trim()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Config panel", () => {
|
||||
test("config panel loads without JS errors", async ({ 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: 25_000 });
|
||||
|
||||
// Filter known pre-existing errors from vendor code
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
1
test/e2e/app/src/entrypoint.ts
Normal file
1
test/e2e/app/src/entrypoint.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "./ha-test";
|
||||
53
test/e2e/app/src/ha-test-panels.ts
Normal file
53
test/e2e/app/src/ha-test-panels.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Panels } from "../../../../src/types";
|
||||
|
||||
export const e2eTestPanels: Panels = {
|
||||
lovelace: {
|
||||
component_name: "lovelace",
|
||||
icon: "mdi:view-dashboard",
|
||||
title: "home",
|
||||
config: { mode: "storage" },
|
||||
url_path: "lovelace",
|
||||
},
|
||||
map: {
|
||||
component_name: "lovelace",
|
||||
icon: "mdi:tooltip-account",
|
||||
title: "map",
|
||||
config: { mode: "storage" },
|
||||
url_path: "map",
|
||||
},
|
||||
energy: {
|
||||
component_name: "energy",
|
||||
icon: "mdi:lightning-bolt",
|
||||
title: "energy",
|
||||
config: null,
|
||||
url_path: "energy",
|
||||
},
|
||||
history: {
|
||||
component_name: "history",
|
||||
icon: "mdi:chart-box",
|
||||
title: "history",
|
||||
config: null,
|
||||
url_path: "history",
|
||||
},
|
||||
config: {
|
||||
component_name: "config",
|
||||
icon: "mdi:cog",
|
||||
title: "config",
|
||||
config: null,
|
||||
url_path: "config",
|
||||
},
|
||||
profile: {
|
||||
component_name: "profile",
|
||||
icon: null,
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "profile",
|
||||
},
|
||||
"developer-tools": {
|
||||
component_name: "developer-tools",
|
||||
icon: "mdi:hammer",
|
||||
title: "developer_tools",
|
||||
config: null,
|
||||
url_path: "developer-tools",
|
||||
},
|
||||
};
|
||||
137
test/e2e/app/src/ha-test.ts
Normal file
137
test/e2e/app/src/ha-test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click";
|
||||
import { navigate } from "../../../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { demoSections } from "../../../../demo/src/configs/sections";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockAssist } from "../../../../demo/src/stubs/assist";
|
||||
import { mockAuth } from "../../../../demo/src/stubs/auth";
|
||||
import { mockCloud } from "../../../../demo/src/stubs/cloud";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEnergy } from "../../../../demo/src/stubs/energy";
|
||||
import { energyEntities } from "../../../../demo/src/stubs/entities";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockEvents } from "../../../../demo/src/stubs/events";
|
||||
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
|
||||
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
|
||||
import { mockPersistentNotification } from "../../../../demo/src/stubs/persistent_notification";
|
||||
import { mockRecorder } from "../../../../demo/src/stubs/recorder";
|
||||
import { mockSensor } from "../../../../demo/src/stubs/sensor";
|
||||
import { mockSystemLog } from "../../../../demo/src/stubs/system_log";
|
||||
import { mockTemplate } from "../../../../demo/src/stubs/template";
|
||||
import { mockTodo } from "../../../../demo/src/stubs/todo";
|
||||
import { mockTranslations } from "../../../../demo/src/stubs/translations";
|
||||
import { mockUpdate } from "../../../../demo/src/stubs/update";
|
||||
import { e2eTestPanels } from "./ha-test-panels";
|
||||
import { scenarios } from "./scenarios";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockHass: MockHomeAssistant;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-test")
|
||||
export class HaTest extends HomeAssistantAppEl {
|
||||
protected async _initializeHass() {
|
||||
const scenarioName =
|
||||
new URLSearchParams(window.location.search).get("scenario") ?? "default";
|
||||
const scenario = Object.prototype.hasOwnProperty.call(
|
||||
scenarios,
|
||||
scenarioName
|
||||
)
|
||||
? scenarios[scenarioName as keyof typeof scenarios]
|
||||
: scenarios.default;
|
||||
|
||||
const initial: Partial<MockHomeAssistant> = {
|
||||
// Use the full panel map (history + config + developer-tools enabled)
|
||||
panels: e2eTestPanels,
|
||||
panelUrl: (() => {
|
||||
const path = window.location.pathname;
|
||||
const dividerPos = path.indexOf("/", 1);
|
||||
return dividerPos === -1
|
||||
? path.substring(1)
|
||||
: path.substring(1, dividerPos);
|
||||
})(),
|
||||
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
() => this.hass!.localize
|
||||
);
|
||||
|
||||
// Register all stubs
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTodo(hass);
|
||||
mockSensor(hass);
|
||||
mockSystemLog(hass);
|
||||
mockTemplate(hass);
|
||||
mockEvents(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockFrontend(hass);
|
||||
mockEnergy(hass);
|
||||
mockUpdate(hass);
|
||||
mockCloud(hass);
|
||||
mockAssist(hass);
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistry(hass, []);
|
||||
mockConfigEntries(hass);
|
||||
mockIcons(hass);
|
||||
mockPersistentNotification(hass);
|
||||
|
||||
// Load default entities from the sections config
|
||||
hass.addEntities(energyEntities());
|
||||
Promise.all([Promise.resolve(demoSections), localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
hass.addEntities(conf.entities(localize));
|
||||
}
|
||||
);
|
||||
|
||||
// Apply scenario customisations (may add entities, change user, set theme,
|
||||
// navigate to a panel, etc.)
|
||||
await scenario(hass);
|
||||
|
||||
// Expose mock handle for Playwright tests to call imperatively
|
||||
window.__mockHass = hass;
|
||||
|
||||
// SPA navigation
|
||||
document.body.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const href = isNavigationClick(e);
|
||||
if (!href) return;
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
this.hassConnected();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-test": HaTest;
|
||||
}
|
||||
}
|
||||
41
test/e2e/app/src/html/index.html.template
Normal file
41
test/e2e/app/src/html/index.html.template
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Home Assistant E2E Test App</title>
|
||||
<%= renderTemplate("../../../../../src/html/_header.html.template") %>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<style>
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: var(--primary-background-color, #111111);
|
||||
color: var(--primary-text-color, #e1e1e1);
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 400;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<ha-test></ha-test>
|
||||
<%= renderTemplate("../../../../../src/html/_js_base.html.template") %>
|
||||
<%= renderTemplate("../../../../../src/html/_preload_roboto.html.template") %>
|
||||
<%= renderTemplate("../../../../../src/html/_script_loader.html.template") %>
|
||||
</body>
|
||||
</html>
|
||||
79
test/e2e/app/src/scenarios/index.ts
Normal file
79
test/e2e/app/src/scenarios/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
|
||||
// ── Individual scenarios ───────────────────────────────────────────────────
|
||||
|
||||
const defaultScenario: Scenario = async (_hass) => {
|
||||
// Default: admin user, light theme — nothing extra to do, ha-test.ts sets
|
||||
// everything up already.
|
||||
};
|
||||
|
||||
const nonAdminScenario: Scenario = async (hass) => {
|
||||
hass.updateHass({
|
||||
user: {
|
||||
...hass.user!,
|
||||
is_admin: false,
|
||||
is_owner: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const darkThemeScenario: Scenario = async (hass) => {
|
||||
// Force dark mode by setting selectedTheme.dark = true.
|
||||
// _applyTheme() reads selectedTheme.dark to determine darkMode; setting
|
||||
// themes.darkMode directly gets overwritten when hassConnected() fires.
|
||||
hass.updateHass({
|
||||
selectedTheme: {
|
||||
theme: hass.selectedTheme?.theme ?? "default",
|
||||
dark: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const customThemeScenario: Scenario = async (hass) => {
|
||||
hass.mockTheme({
|
||||
"primary-color": "#e91e63",
|
||||
"accent-color": "#ff5722",
|
||||
});
|
||||
};
|
||||
|
||||
const historyPanelScenario: Scenario = async (_hass) => {
|
||||
// Navigation happens after hassConnected — handled by Playwright via URL
|
||||
};
|
||||
|
||||
const configPanelScenario: Scenario = async (_hass) => {
|
||||
// Navigation handled by Playwright via URL
|
||||
};
|
||||
|
||||
const lightMoreInfoScenario: Scenario = async (hass) => {
|
||||
// Make sure we have a light entity available (sections config adds them, but
|
||||
// this ensures it exists synchronously for tests that load mid-init).
|
||||
hass.addEntities([
|
||||
{
|
||||
entity_id: "light.test_light",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Test Light",
|
||||
supported_features: 44,
|
||||
supported_color_modes: ["brightness", "color_temp", "xy"],
|
||||
color_mode: "brightness",
|
||||
brightness: 200,
|
||||
min_mireds: 153,
|
||||
max_mireds: 500,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
default: defaultScenario,
|
||||
"non-admin": nonAdminScenario,
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"history-panel": historyPanelScenario,
|
||||
"config-panel": configPanelScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
};
|
||||
110
test/e2e/browserstack.capabilities.ts
Normal file
110
test/e2e/browserstack.capabilities.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* BrowserStack device/browser capability matrix for e2e tests.
|
||||
*
|
||||
* Each entry maps to a named Playwright project in playwright.demo.config.ts.
|
||||
* Keep the total number of entries at or below the BrowserStack parallel
|
||||
* test limit for this account (5 parallel tests).
|
||||
*
|
||||
* Capability reference:
|
||||
* https://www.browserstack.com/docs/automate/playwright/browsers-and-os
|
||||
*/
|
||||
|
||||
export interface BrowserStackCapabilities {
|
||||
"browserstack.username": string;
|
||||
"browserstack.accessKey": string;
|
||||
"browserstack.local": boolean;
|
||||
"browserstack.localIdentifier": string;
|
||||
browser: string;
|
||||
browser_version?: string;
|
||||
os?: string;
|
||||
os_version: string;
|
||||
device?: string;
|
||||
real_mobile?: boolean;
|
||||
name: string;
|
||||
build: string;
|
||||
project: string;
|
||||
}
|
||||
|
||||
const build = process.env.GITHUB_RUN_ID
|
||||
? `CI #${process.env.GITHUB_RUN_ID}`
|
||||
: "local";
|
||||
|
||||
const localIdentifier = process.env.BROWSERSTACK_LOCAL_IDENTIFIER ?? "default";
|
||||
|
||||
const base: Omit<
|
||||
BrowserStackCapabilities,
|
||||
"browser" | "os_version" | "name" | "device" | "real_mobile" | "os"
|
||||
> = {
|
||||
"browserstack.username": process.env.BROWSERSTACK_USERNAME ?? "",
|
||||
"browserstack.accessKey": process.env.BROWSERSTACK_ACCESS_KEY ?? "",
|
||||
"browserstack.local": true,
|
||||
"browserstack.localIdentifier": localIdentifier,
|
||||
build,
|
||||
project: "Home Assistant Demo e2e",
|
||||
};
|
||||
|
||||
export interface DeviceConfig {
|
||||
/** Name used for the Playwright project (must be unique). */
|
||||
projectName: string;
|
||||
caps: BrowserStackCapabilities;
|
||||
}
|
||||
|
||||
export const browserstackDevices: DeviceConfig[] = [
|
||||
// ── Desktop ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
projectName: "browserstack-chrome-win10",
|
||||
caps: {
|
||||
...base,
|
||||
browser: "chrome",
|
||||
browser_version: "latest",
|
||||
os: "Windows",
|
||||
os_version: "10",
|
||||
name: "Chrome latest / Windows 10",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectName: "browserstack-firefox-macos",
|
||||
caps: {
|
||||
...base,
|
||||
browser: "firefox",
|
||||
browser_version: "latest",
|
||||
os: "OS X",
|
||||
os_version: "Ventura",
|
||||
name: "Firefox latest / macOS Ventura",
|
||||
},
|
||||
},
|
||||
// ── Mobile ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
projectName: "browserstack-safari-ipad-ios12",
|
||||
caps: {
|
||||
...base,
|
||||
browser: "safari",
|
||||
os_version: "12",
|
||||
device: "iPad 6th",
|
||||
real_mobile: true,
|
||||
name: "Safari / iPad 6th gen / iOS 12",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectName: "browserstack-safari-iphone-ios14",
|
||||
caps: {
|
||||
...base,
|
||||
browser: "safari",
|
||||
os_version: "14",
|
||||
device: "iPhone 12",
|
||||
real_mobile: true,
|
||||
name: "Safari / iPhone 12 / iOS 14",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectName: "browserstack-chrome-android8",
|
||||
caps: {
|
||||
...base,
|
||||
browser: "chrome",
|
||||
os_version: "8.0",
|
||||
device: "Samsung Galaxy S9",
|
||||
real_mobile: true,
|
||||
name: "Chrome / Samsung Galaxy S9 / Android 8",
|
||||
},
|
||||
},
|
||||
];
|
||||
29
test/e2e/collect-blob-reports.mjs
Normal file
29
test/e2e/collect-blob-reports.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Collects blob reports from each suite into a single staging directory so
|
||||
// `playwright merge-reports` can consume them from one path.
|
||||
//
|
||||
// Usage: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
import { cpSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
|
||||
const dest = "test/e2e/reports/blob";
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const suite of ["demo", "app", "gallery"]) {
|
||||
const src = `test/e2e/reports/${suite}`;
|
||||
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(
|
||||
`Warning: no blob reports found for suite "${suite}" (${src} missing), skipping.\n`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
cpSync(`${src}/${file}`, `${dest}/${suite}-${file}`);
|
||||
}
|
||||
}
|
||||
129
test/e2e/demo.spec.ts
Normal file
129
test/e2e/demo.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
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("/");
|
||||
});
|
||||
|
||||
// ── 1. Page loads ──────────────────────────────────────────────────────────
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
// The custom element is present in the document
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
|
||||
// The launch screen should disappear once the app is ready
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// No unhandled JS exceptions
|
||||
expect(pageErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── 2. Dashboard renders ───────────────────────────────────────────────────
|
||||
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
// Wait for the app shell to be ready
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Lovelace cards are rendered inside the shadow DOM.
|
||||
// Playwright pierces shadow roots with CSS selectors automatically.
|
||||
// We wait for at least one hui-* card element to appear.
|
||||
const card = page.locator("[class*='hui-']").first();
|
||||
// Alternatively match by the lovelace view container:
|
||||
const lovelaceView = page
|
||||
.locator(
|
||||
"hui-masonry-view, hui-sections-view, hui-panel-view, hui-sidebar-view"
|
||||
)
|
||||
.first();
|
||||
|
||||
// One of the two approaches should succeed — wait for whichever is present
|
||||
await Promise.race([
|
||||
lovelaceView.waitFor({ state: "attached", timeout: 30_000 }),
|
||||
card.waitFor({ state: "attached", timeout: 30_000 }),
|
||||
]);
|
||||
|
||||
// At least one card must be visible
|
||||
const cards = page.locator(
|
||||
"hui-tile-card, hui-entity-card, hui-glance-card, hui-button-card, hui-markdown-card"
|
||||
);
|
||||
await expect(cards.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ── 3. Sidebar navigation ─────────────────────────────────────────────────
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The sidebar uses ha-list-item-button elements with id="sidebar-panel-{url}"
|
||||
// Pick "map" as a reliable, always-present demo panel. Fall back to
|
||||
// "logbook" or "history" if map isn't available.
|
||||
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
|
||||
if ((await navItem.count()) > 0) {
|
||||
// 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: 15_000,
|
||||
});
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(clicked, "No known sidebar panel was found to click").toBe(true);
|
||||
expect(pageErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── 4. More info dialog ───────────────────────────────────────────────────
|
||||
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator("ha-demo")).toBeAttached({ timeout: 30_000 });
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Navigate to the default dashboard (root) in case a previous test
|
||||
// already navigated away.
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#ha-launch-screen")).toBeHidden({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Tile cards are the most common card type in the demo configs; they are
|
||||
// clickable and open the more-info dialog.
|
||||
const tileCard = page.locator("hui-tile-card").first();
|
||||
await tileCard.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await tileCard.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: 15_000 });
|
||||
|
||||
// The title is a slotted <span> in the light DOM — visible and has size.
|
||||
const title = dialog.locator("span.title");
|
||||
await expect(title).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
expect(pageErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
344
test/e2e/gallery.spec.ts
Normal file
344
test/e2e/gallery.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* E2E tests for the HA gallery (port 8100).
|
||||
*
|
||||
* Each component page is tested by navigating to its hash and asserting that
|
||||
* the demo content renders without JS errors and the page element is visible.
|
||||
*
|
||||
* Run with:
|
||||
* yarn test:e2e:gallery:local
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 gallery to finish rendering the page content inside its shadow root
|
||||
await page.waitForFunction(() => {
|
||||
const gallery = document.querySelector("ha-gallery") as any;
|
||||
return gallery?.shadowRoot?.querySelector("page-description") != null;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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: 15_000,
|
||||
});
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("ResizeObserver") &&
|
||||
!e.includes("Non-Error") &&
|
||||
!e.includes("Extension context") &&
|
||||
!e.includes("this.localize is not a function") &&
|
||||
// Gallery throws plain objects (e.g. from WebSocket/data-fetch) that
|
||||
// show up as "Object" with no stack — not real JS errors.
|
||||
e !== "Object" &&
|
||||
// hui-group-entity-row tries to call .some() on a potentially undefined
|
||||
// entity_id array from mock state data — pre-existing gallery data issue.
|
||||
!e.includes("Cannot read properties of undefined (reading 'some')")
|
||||
);
|
||||
expect(
|
||||
realErrors,
|
||||
`JS errors on ${hash}: ${realErrors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Gallery shell", () => {
|
||||
test("page loads and ha-gallery mounts", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("ha-gallery")).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) => !e.includes("ResizeObserver") && !e.includes("Non-Error")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sidebar renders navigation links", async ({ page }) => {
|
||||
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 >> mwc-drawer")).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 }) => {
|
||||
await goToGalleryPage(page, "components/ha-alert");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-alert");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
// 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.first()).toBeAttached({ timeout: 10_000 });
|
||||
// There should be at least 4 alerts (one per type)
|
||||
await expect(alerts)
|
||||
.toHaveCount(4, { timeout: 10_000 })
|
||||
.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 }) => {
|
||||
await goToGalleryPage(page, "components/ha-button");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-button");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-button, mwc-button").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-control-slider can be found in DOM", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-control-slider");
|
||||
const demo = page.locator(
|
||||
"ha-gallery >> demo-components-ha-control-slider"
|
||||
);
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-control-slider").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-form renders schema-driven fields", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-form");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-form");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("ha-form").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
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: 15_000 });
|
||||
});
|
||||
|
||||
test("tile-card renders entity state", async ({ page }) => {
|
||||
await goToGalleryPage(page, "lovelace/tile-card");
|
||||
const demo = page.locator("ha-gallery >> demo-lovelace-tile-card");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
await expect(demo.locator("hui-tile-card").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("more-info light renders controls", async ({ page }) => {
|
||||
await goToGalleryPage(page, "more-info/light");
|
||||
const demo = page.locator("ha-gallery >> demo-more-info-light");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
// 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: 15_000 });
|
||||
});
|
||||
|
||||
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: 15_000 });
|
||||
});
|
||||
|
||||
test("ha-gauge renders a gauge element", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-gauge");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-gauge");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
// ha-gauge page is markdown-based; gauge elements render in the description area
|
||||
await expect(page.locator("ha-gallery >> ha-gauge").first()).toBeAttached({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("ha-switch toggles state", async ({ page }) => {
|
||||
await goToGalleryPage(page, "components/ha-switch");
|
||||
const demo = page.locator("ha-gallery >> demo-components-ha-switch");
|
||||
await expect(demo).toBeAttached({ timeout: 15_000 });
|
||||
const switchEl = demo.locator("ha-switch").first();
|
||||
await expect(switchEl).toBeAttached({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
65
test/e2e/playwright.app.config.ts
Normal file
65
test/e2e/playwright.app.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { browserstackDevices } from "./browserstack.capabilities";
|
||||
|
||||
const APP_PORT = 8095;
|
||||
const APP_BASE_URL = `http://localhost:${APP_PORT}`;
|
||||
|
||||
const isBrowserStack = Boolean(process.env.BROWSERSTACK);
|
||||
|
||||
function browserstackCdpUrl(caps: Record<string, unknown>): string {
|
||||
const encoded = encodeURIComponent(JSON.stringify(caps));
|
||||
return `wss://cdp.browserstack.com/playwright?caps=${encoded}`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "app.spec.ts",
|
||||
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
|
||||
workers: isBrowserStack ? 5 : undefined,
|
||||
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/app" }]],
|
||||
|
||||
use: {
|
||||
baseURL: APP_BASE_URL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: isBrowserStack
|
||||
? browserstackDevices.map(({ projectName, caps }) => ({
|
||||
name: projectName,
|
||||
use: {
|
||||
connectOptions: { wsEndpoint: browserstackCdpUrl({ ...caps }) },
|
||||
...(caps.real_mobile
|
||||
? { isMobile: true }
|
||||
: { viewport: { width: 1280, height: 800 } }),
|
||||
},
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: "local",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? `npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`
|
||||
: `./node_modules/.bin/gulp build-e2e-test-app && npx serve test/e2e/app/dist -p ${APP_PORT} --no-clipboard -s`,
|
||||
url: APP_BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: process.env.CI ? 30_000 : 600_000,
|
||||
cwd:
|
||||
process.env.GITHUB_WORKSPACE ??
|
||||
new URL("../..", import.meta.url).pathname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
104
test/e2e/playwright.demo.config.ts
Normal file
104
test/e2e/playwright.demo.config.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { browserstackDevices } from "./browserstack.capabilities";
|
||||
|
||||
// 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`
|
||||
// works out of the box locally — Playwright will reuse the already-running
|
||||
// server instead of starting a new one.
|
||||
// In CI we serve the pre-built demo/dist on the same port.
|
||||
const DEMO_PORT = 8090;
|
||||
const DEMO_BASE_URL = `http://localhost:${DEMO_PORT}`;
|
||||
|
||||
const isBrowserStack = Boolean(process.env.BROWSERSTACK);
|
||||
|
||||
/**
|
||||
* Build a BrowserStack CDP WebSocket URL from capability objects.
|
||||
* Playwright connects to BrowserStack via their CDP endpoint and passes
|
||||
* all capabilities as URL-encoded JSON.
|
||||
*
|
||||
* Docs: https://www.browserstack.com/docs/automate/playwright/getting-started
|
||||
*/
|
||||
function browserstackCdpUrl(caps: Record<string, unknown>): string {
|
||||
const encoded = encodeURIComponent(JSON.stringify(caps));
|
||||
return `wss://cdp.browserstack.com/playwright?caps=${encoded}`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// All test files live under test/e2e/
|
||||
testDir: ".",
|
||||
testMatch: "demo.spec.ts",
|
||||
|
||||
// Give the demo plenty of time to load — especially on real mobile devices
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
// Each test gets one retry on CI; locally we fail fast
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
|
||||
// BrowserStack recommends no more parallelism than your plan's limit (5).
|
||||
// Locally we just use the default (# of CPUs).
|
||||
workers: isBrowserStack ? 5 : undefined,
|
||||
|
||||
// Keep all output under test/e2e/ so it sits alongside the tests and
|
||||
// is easy to find, gitignore, and upload as a CI artifact.
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/demo" }]],
|
||||
|
||||
use: {
|
||||
baseURL: DEMO_BASE_URL,
|
||||
// Capture trace + screenshot on first retry so failures are easy to debug
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: isBrowserStack
|
||||
? // ── BrowserStack projects ─────────────────────────────────────────────
|
||||
browserstackDevices.map(({ projectName, caps }) => ({
|
||||
name: projectName,
|
||||
use: {
|
||||
// Tell Playwright to connect to BrowserStack's remote browser
|
||||
// instead of launching a local one.
|
||||
connectOptions: {
|
||||
wsEndpoint: browserstackCdpUrl({ ...caps }),
|
||||
},
|
||||
// Use a viewport appropriate for the device type
|
||||
...(caps.real_mobile
|
||||
? { isMobile: true }
|
||||
: { viewport: { width: 1280, height: 800 } }),
|
||||
},
|
||||
}))
|
||||
: // ── Local project ─────────────────────────────────────────────────────
|
||||
[
|
||||
{
|
||||
name: "local",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Serve the demo for tests.
|
||||
// - Locally: if `develop_demo` is already running on port 8090, Playwright
|
||||
// reuses it. Otherwise it builds demo/dist and serves it.
|
||||
// Running `develop_demo` first is the recommended local workflow.
|
||||
// - In CI: demo/dist is downloaded from the build-demo artifact before this
|
||||
// runs, so we skip the build and go straight to serving.
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? `npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`
|
||||
: `./node_modules/.bin/gulp build-demo && npx serve demo/dist -p ${DEMO_PORT} --no-clipboard`,
|
||||
url: DEMO_BASE_URL,
|
||||
// Reuse the develop_demo dev server if it is already running locally.
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// Allow up to 5 minutes locally for the demo build + serve startup.
|
||||
timeout: process.env.CI ? 30_000 : 300_000,
|
||||
// Run from the repo root so `demo/dist` resolves correctly.
|
||||
// This config lives at test/e2e/, so two levels up is the repo root.
|
||||
cwd:
|
||||
process.env.GITHUB_WORKSPACE ??
|
||||
new URL("../..", import.meta.url).pathname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
65
test/e2e/playwright.gallery.config.ts
Normal file
65
test/e2e/playwright.gallery.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { browserstackDevices } from "./browserstack.capabilities";
|
||||
|
||||
const GALLERY_PORT = 8100;
|
||||
const GALLERY_BASE_URL = `http://localhost:${GALLERY_PORT}`;
|
||||
|
||||
const isBrowserStack = Boolean(process.env.BROWSERSTACK);
|
||||
|
||||
function browserstackCdpUrl(caps: Record<string, unknown>): string {
|
||||
const encoded = encodeURIComponent(JSON.stringify(caps));
|
||||
return `wss://cdp.browserstack.com/playwright?caps=${encoded}`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "gallery.spec.ts",
|
||||
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
|
||||
workers: isBrowserStack ? 5 : undefined,
|
||||
|
||||
outputDir: "test-results",
|
||||
reporter: [["list"], ["blob", { outputDir: "reports/gallery" }]],
|
||||
|
||||
use: {
|
||||
baseURL: GALLERY_BASE_URL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: isBrowserStack
|
||||
? browserstackDevices.map(({ projectName, caps }) => ({
|
||||
name: projectName,
|
||||
use: {
|
||||
connectOptions: { wsEndpoint: browserstackCdpUrl({ ...caps }) },
|
||||
...(caps.real_mobile
|
||||
? { isMobile: true }
|
||||
: { viewport: { width: 1280, height: 800 } }),
|
||||
},
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: "local",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? `npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`
|
||||
: `./node_modules/.bin/gulp build-gallery && npx serve gallery/dist -p ${GALLERY_PORT} --no-clipboard -s`,
|
||||
url: GALLERY_BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: process.env.CI ? 30_000 : 600_000,
|
||||
cwd:
|
||||
process.env.GITHUB_WORKSPACE ??
|
||||
new URL("../..", import.meta.url).pathname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
8
test/e2e/playwright.merge.config.ts
Normal file
8
test/e2e/playwright.merge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
["html", { outputFolder: "reports/combined", open: "never" }],
|
||||
["json", { outputFile: "reports/combined/results.json" }],
|
||||
],
|
||||
});
|
||||
15
test/e2e/tsconfig.json
Normal file
15
test/e2e/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
// e2e tests run via Playwright's Node.js runner, not the browser bundler
|
||||
"lib": ["ES2021"],
|
||||
// Playwright's types use modern module resolution
|
||||
"moduleResolution": "bundler",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
// Clear the exclude from the root tsconfig so we include our own files
|
||||
"exclude": []
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig, configDefaults } from "vitest/config";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, "test/e2e/**"],
|
||||
environment: "jsdom", // to run in browser-like environment
|
||||
env: {
|
||||
TZ: "Etc/UTC",
|
||||
|
||||
@@ -157,5 +157,7 @@
|
||||
"lit/directives/join": ["./node_modules/lit/directives/join.js"],
|
||||
"lit/directives/ref": ["./node_modules/lit/directives/ref.js"]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Exclude e2e tests — they have their own tsconfig that adds node types.
|
||||
"exclude": ["test/e2e"]
|
||||
}
|
||||
|
||||
55
yarn.lock
55
yarn.lock
@@ -3181,6 +3181,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:1.59.1":
|
||||
version: 1.59.1
|
||||
resolution: "@playwright/test@npm:1.59.1"
|
||||
dependencies:
|
||||
playwright: "npm:1.59.1"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/27a894c4d4216b51cddc96e18fd0638a9e2e0a3f0b7ee32a56121fb61df395ec43529f5dcdca32578af8a34a04722ee3767f99f0ae4d39fa8edceda89a96014c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@replit/codemirror-indentation-markers@npm:6.5.3":
|
||||
version: 6.5.3
|
||||
resolution: "@replit/codemirror-indentation-markers@npm:6.5.3"
|
||||
@@ -7625,6 +7636,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@npm:2.3.2":
|
||||
version: 2.3.2
|
||||
resolution: "fsevents@npm:2.3.2"
|
||||
dependencies:
|
||||
node-gyp: "npm:latest"
|
||||
checksum: 10/6b5b6f5692372446ff81cf9501c76e3e0459a4852b3b5f1fc72c103198c125a6b8c72f5f166bdd76ffb2fca261e7f6ee5565daf80dca6e571e55bcc589cc1256
|
||||
conditions: os=darwin
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
|
||||
version: 2.3.3
|
||||
resolution: "fsevents@npm:2.3.3"
|
||||
@@ -7635,6 +7656,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
|
||||
version: 2.3.2
|
||||
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
|
||||
dependencies:
|
||||
node-gyp: "npm:latest"
|
||||
conditions: os=darwin
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
|
||||
version: 2.3.3
|
||||
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
|
||||
@@ -8181,6 +8211,7 @@ __metadata:
|
||||
"@octokit/auth-oauth-device": "npm:8.0.3"
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.59.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.5.9"
|
||||
"@rspack/core": "npm:2.0.1"
|
||||
@@ -10624,6 +10655,30 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.59.1":
|
||||
version: 1.59.1
|
||||
resolution: "playwright-core@npm:1.59.1"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/d27857a6701587c2a9bfa26fed9a5d8c617a392299b99b187f2ddc198d012a1e296449806bc907220debea938152677e8b4d91d304ed00645f762f778de3abec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.59.1":
|
||||
version: 1.59.1
|
||||
resolution: "playwright@npm:1.59.1"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.59.1"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/17b2df42effa362adc6aa3192b625bd80f26b91a0c253a2375ac89ace68407b746dd87b4081629c50c58c3cb031c5b837a32fef43a3c98c60ea504e0b001e5fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"plugin-error@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "plugin-error@npm:1.0.1"
|
||||
|
||||
Reference in New Issue
Block a user