Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ on:
- "main"
types: [opened, synchronize, reopened]

permissions:
contents: read
issues: write
pull-requests: write
checks: read

jobs:
# Job 1: Python Linting
python-lint:
Expand Down Expand Up @@ -150,51 +156,99 @@ jobs:
const pythonLintResult = '${{ needs.python-lint.result }}';
const typescriptResult = '${{ needs.typescript-check.result }}';
const buildResult = '${{ needs.build-and-test.result }}';

const headSha = context.payload.pull_request?.head?.sha || context.sha;

const statusEmoji = (result) => {
if (result === 'success') return '✅';
if (result === 'failure') return '❌';
if (result === 'skipped') return '⏭️';
if (result === 'pending') return '⏳';
return '⚠️';
};

const statusText = (result) => {
if (result === 'success') return 'Passed';
if (result === 'failure') return 'Failed';
if (result === 'skipped') return 'Skipped';
if (result === 'pending') return 'Pending';
return 'Warning';
};


const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const waitForPlaywrightResult = async () => {
const deadline = Date.now() + 10 * 60 * 1000;

while (Date.now() < deadline) {
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha,
});

const playwrightRuns = checks.check_runs.filter((check) => check.name === 'Playwright E2E');
const playwrightLatest = playwrightRuns.sort((a, b) => new Date(b.started_at || b.created_at).getTime() - new Date(a.started_at || a.created_at).getTime())[0];

if (playwrightLatest && playwrightLatest.conclusion) {
return playwrightLatest.conclusion;
}

if (playwrightLatest && ['completed', 'cancelled', 'neutral', 'success', 'failure', 'skipped', 'timed_out', 'action_required', 'stale'].includes(playwrightLatest.status)) {
return playwrightLatest.conclusion || playwrightLatest.status || 'pending';
}

await sleep(15000);
}

const { data: finalChecks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha,
});

const finalRuns = finalChecks.check_runs.filter((check) => check.name === 'Playwright E2E');
const finalLatest = finalRuns.sort((a, b) => new Date(b.started_at || b.created_at).getTime() - new Date(a.started_at || a.created_at).getTime())[0];
return finalLatest ? (finalLatest.conclusion || finalLatest.status || 'pending') : 'pending';
};

const playwrightResult = await waitForPlaywrightResult();

let body = `## 🔍 CI Check Results\n\n`;
body += `| Check | Status | Details |\n`;
body += `|-------|--------|----------|\n`;
body += `| **Python Lint (Ruff)** | ${statusEmoji(pythonLintResult)} | ${statusText(pythonLintResult)} |\n`;
body += `| **TypeScript & ESLint** | ${statusEmoji(typescriptResult)} | ${statusText(typescriptResult)} (non-blocking) |\n`;
body += `| **Build & Smoke Tests** | ${statusEmoji(buildResult)} | ${statusText(buildResult)} |\n\n`;

body += `| **Build & Smoke Tests** | ${statusEmoji(buildResult)} | ${statusText(buildResult)} |\n`;
body += `| **Playwright E2E** | ${statusEmoji(playwrightResult)} | ${statusText(playwrightResult)} |\n\n`;

if (buildResult === 'failure') {
body += `### ❌ Build or Smoke Tests Failed\n\n`;
body += `The build or smoke tests failed. This PR cannot be merged until the issue is resolved.\n\n`;
}


if (playwrightResult === 'failure') {
body += `### ❌ Playwright E2E Failed\n\n`;
body += `The Playwright E2E checks failed. This PR cannot be merged until they pass.\n\n`;
}

if (pythonLintResult === 'failure') {
body += `### ⚠️ Python Linting Issues\n\n`;
body += `Run \`python3 -m ruff check . --fix\` to auto-fix issues.\n\n`;
}

if (typescriptResult !== 'success') {
body += `### ℹ️ TypeScript/ESLint Issues (Non-blocking)\n\n`;
body += `TypeScript and ESLint checks found issues but are non-blocking.\n`;
body += `Consider fixing these in future commits:\n`;
body += `- Run \`npm run lint\` to auto-fix ESLint issues\n`;
body += `- Run \`npm run type-check\` to see TypeScript errors\n\n`;
}
if (pythonLintResult === 'success' && buildResult === 'success') {

if (pythonLintResult === 'success' && buildResult === 'success' && playwrightResult === 'success') {
body += `### ✅ All Required Checks Passed!\n\n`;
body += `This PR is ready for review and can be merged.\n\n`;
}

body += `---\n`;
body += `*Automated CI check • [View workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId})*`;

Expand Down
139 changes: 139 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
name: "E2E Diagnostics"

on:
workflow_dispatch:
pull_request:
branches:
- "main"
push:
branches:
- "main"

permissions:
contents: read
pages: write
id-token: write
actions: read
issues: write
pull-requests: write

jobs:
e2e:
name: "Playwright E2E"
runs-on: "ubuntu-latest"
timeout-minutes: 20

steps:
- name: "Checkout repository"
uses: actions/checkout@v6

- name: "Set up Node.js"
uses: actions/setup-node@v5
with:
node-version: 24
cache: npm

- name: "Install dependencies"
run: npm ci

- name: "Build dashboard bundle"
run: npm run build

- name: "Install Playwright browsers"
run: npx playwright install --with-deps chromium

- name: "Run Playwright E2E tests"
run: npm run test:e2e

- name: "Prepare Playwright report for GitHub Pages"
id: pages
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
run: |
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
REPORT_ID="pr-${{ github.event.pull_request.number }}/${GITHUB_SHA}"
else
REPORT_ID="push/${GITHUB_SHA}"
fi

echo "report_id=$REPORT_ID" >> "$GITHUB_OUTPUT"

mkdir -p pages/playwright-report/$REPORT_ID
cp -R playwright-report/. pages/playwright-report/$REPORT_ID/

mkdir -p pages/playwright-report
cat > pages/playwright-report/index.html <<EOF
<!doctype html>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=./$REPORT_ID/">
<a href="./$REPORT_ID/">Open latest Playwright report</a>
EOF

- name: "Upload Playwright Pages artifact"
if: steps.pages.outputs.report_id != ''
uses: actions/upload-pages-artifact@v3
with:
path: ./pages

- name: "Deploy Playwright report to GitHub Pages"
if: steps.pages.outputs.report_id != ''
id: deployment
continue-on-error: true
uses: actions/deploy-pages@v4

- name: "Upload Playwright failure artifacts"
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-failure-artifacts
path: test-results/
retention-days: 7

- name: "Post PR report summary"
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
with:
script: |
const prNumber = context.payload.pull_request.number;
const headSha = context.payload.pull_request.head.sha;
const pageUrl = process.env.PAGE_URL || '';
const reportUrl = pageUrl ? `${pageUrl}playwright-report/pr-${prNumber}/${headSha}/` : `${context.payload.repository.html_url}/actions/runs/${context.runId}`;
const workflowUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`;

const body = `## 🎥 Playwright E2E report\n\n` +
`Open the report directly in the browser: [Playwright HTML report](${reportUrl})\n\n` +
`### What is covered\n` +
`- dashboard generation completes without errors\n` +
`- entity ↔ area ↔ floor mappings stay valid\n` +
`- generated views exist for every area and floor\n` +
`- the visible summary matches the fixture counts\n\n` +
`### Debug artifacts\n` +
`- screenshots, videos, and traces are uploaded in the GitHub Actions run when the test fails\n\n` +
`### Workflow run\n` +
`- [Open the full GitHub Actions run](${workflowUrl})\n`;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const marker = '## 🎥 Playwright E2E report';
const existing = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ yarn-debug.log*
yarn-error.log*
.npm

# Playwright artifacts
playwright-report/
test-results/

# Home Assistant configuration
config/*
!config/configuration.yaml
Expand Down
52 changes: 52 additions & 0 deletions build-scripts/build.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const path = require("path");
const esbuild = require("esbuild");
const env = require("./env.cjs");

const args = new Set(process.argv.slice(2));
const watch = args.has("--watch");
const prod = args.has("--prod") || process.env.NODE_ENV === "production" || env.isProdBuild();

const rootDir = path.resolve(__dirname, "..");
const entryPoint = path.resolve(rootDir, "src/linus-strategy.ts");
const outFile = path.resolve(rootDir, "custom_components/linus_dashboard/www/linus-strategy.js");

const commonOptions = {
entryPoints: [entryPoint],
bundle: true,
outfile: outFile,
format: "iife",
platform: "browser",
target: ["es2017"],
sourcemap: true,
minify: prod,
treeShaking: true,
legalComments: "none",
loader: {
".md": "empty",
},
define: {
__BUILD__: JSON.stringify(env.version()),
__DEV__: JSON.stringify(!prod),
__LINUS_DASHBOARD__: "true",
__STATIC_PATH__: JSON.stringify("/static/linus_dashboard/"),
__VERSION__: JSON.stringify(env.version()),
"process.env.NODE_ENV": JSON.stringify(prod ? "production" : "development"),
},
};

async function main() {
if (watch) {
const ctx = await esbuild.context(commonOptions);
await ctx.watch();
console.log(`[build] watching ${entryPoint}`);
return;
}

await esbuild.build(commonOptions);
console.log(`[build] wrote ${outFile}`);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
674 changes: 672 additions & 2 deletions custom_components/linus_dashboard/www/linus-strategy.js

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion custom_components/linus_dashboard/www/linus-strategy.js.map

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions docs/E2E_TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Linus Dashboard E2E testing

This repository now includes a Playwright-based regression harness that runs against a versioned Home Assistant fixture.

## What it checks

- dashboard generation still succeeds
- the entity ↔ device ↔ area ↔ floor mapping stays valid
- generated views keep their expected shape
- the visible summary cards match the fixture counts
- failures capture screenshots, video, and trace artifacts automatically

## What you can inspect in the MR

- the GitHub Actions comment now links to the live Playwright HTML report on GitHub Pages when Pages is enabled
- on CI, Playwright records videos so you can visually inspect what the test exercised
- on failure, the screenshots, trace, and video are uploaded as debug artifacts in the workflow run
- the report URL is stable per PR/commit once Pages is enabled, so you can open it directly in the browser

## Coverage matrix

| User intent | Current test coverage | Proof in artifacts |
|---|---|---|
| Dashboard generates successfully | `should generate dashboard with views` | Video + HTML report |
| Areas/floors are still mapped | area/floor assertions in `strategy.spec.ts` | HTML report + trace |
| Summary matches the fixture | summary count assertions | Screenshot + HTML report |
| Runtime is reasonable | timing assertion | Trace + logs |

## Fixture strategy

The fixture lives in `tests/ha-fixture/` and should be updated only when the Home Assistant test instance changes in a deliberate way.
It is meant to preserve the stable mapping needed by Linus Dashboard, especially entity ↔ area relationships.

## Local run

```bash
npm ci
npm run build
npm run test:e2e
```

## Failure artifacts

When a test fails, Playwright writes artifacts to `test-results/` and the HTML report to `playwright-report/`.
Those directories are uploaded by GitHub Actions in the `E2E Diagnostics` workflow.

## Updating the fixture

1. Export registry snapshots from Home Assistant.
2. Update `tests/ha-fixture/*`.
3. Keep the entity ↔ area mapping consistent with the dashboard layout.
4. Bump `tests/ha-fixture/fixture-version.json`.
Loading
Loading