From 53c9fe1f9b74ad4af62c918e7166ae103e1fdc6d Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:37:04 -0600 Subject: [PATCH 01/11] test: cover state conditions and custom fields in YAML round trips --- tests/yamlRoundTrip.test.cjs | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/yamlRoundTrip.test.cjs b/tests/yamlRoundTrip.test.cjs index 5bb7b58..8b5cf99 100644 --- a/tests/yamlRoundTrip.test.cjs +++ b/tests/yamlRoundTrip.test.cjs @@ -86,3 +86,52 @@ test('retains JavaScript action templates', () => { test('rejects invalid YAML instead of fabricating an empty configuration', () => { assert.throws(() => parseButtonCardYaml('entity: [unterminated'), /./); }); + +test('preserves template custom fields and their style declarations', () => { + const value = '[[[ return entity.state === "on" ? "Active" : "Idle"; ]]]'; + const { imported, parsed } = roundTrip({ + type: 'custom:button-card', + entity: 'switch.desk', + custom_fields: { status: value }, + styles: { custom_fields: { status: ['color: #33cc66', 'font-weight: bold'] } }, + }); + assert.equal(imported.customFields[0].type, 'template'); + assert.equal(parsed.custom_fields.status.trim(), value); + assert.deepEqual(parsed.styles.custom_fields.status, ['color: #33cc66', 'font-weight: bold']); +}); + +test('retains a regex state override with its icon and label', () => { + const { imported, parsed } = roundTrip({ + type: 'custom:button-card', + entity: 'sensor.machine', + state: [{ operator: 'regex', value: '^error', icon: 'mdi:alert', label: 'Needs attention' }], + }); + assert.equal(imported.stateStyles.length, 1); + const state = parsed.state.find(x => x.operator === 'regex'); + assert.ok(state); + assert.equal(state.value, '^error'); + assert.equal(state.icon, 'mdi:alert'); + assert.equal(state.label, 'Needs attention'); +}); + +test('preserves the default-state fallback after import and export', () => { + const { parsed } = roundTrip({ + type: 'custom:button-card', + entity: 'sensor.machine', + state: [{ operator: 'default', name: 'Normal operation', icon: 'mdi:check' }], + }); + const fallback = parsed.state.find(x => x.operator === 'default'); + assert.ok(fallback); + assert.equal(fallback.name, 'Normal operation'); + assert.equal(fallback.icon, 'mdi:check'); +}); + +test('preserves custom-field text with YAML-special characters', () => { + const value = 'Floor 2: "Office" #1'; + const { parsed } = roundTrip({ + type: 'custom:button-card', + entity: 'switch.office', + custom_fields: { location: value }, + }); + assert.equal(parsed.custom_fields.location, value); +}); From cf6de577b7ee05698a1f5e82f901b77b9c05f469 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:37:58 -0600 Subject: [PATCH 02/11] test: smoke-test real editor render and theme toggle in Chromium --- tests/browserSmoke.cjs | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/browserSmoke.cjs diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs new file mode 100644 index 0000000..0369049 --- /dev/null +++ b/tests/browserSmoke.cjs @@ -0,0 +1,48 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { chromium } = require('playwright'); + +const address = 'http://127.0.0.1:4173/button_builder/'; +const server = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', +}); +let logs = ''; +server.stdout.on('data', chunk => { logs += chunk.toString(); }); +server.stderr.on('data', chunk => { logs += chunk.toString(); }); + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function ready() { + for (let i = 0; i < 80; i++) { + if (server.exitCode !== null) throw new Error('Vite exited early: ' + logs); + try { + const response = await fetch(address, { signal: AbortSignal.timeout(1000) }); + if (response.ok) return; + } catch {} + await sleep(250); + } + throw new Error('Vite did not become ready: ' + logs); +} +(async () => { + let browser; + try { + await ready(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(address, { waitUntil: 'domcontentloaded' }); + await page.getByRole('heading', { name: 'Button Card Builder' }).waitFor({ timeout: 20000 }); + await page.getByText('Live Preview', { exact: true }).first().waitFor({ timeout: 15000 }); + assert.equal(errors.length, 0, 'Uncaught browser exceptions: ' + errors.join('; ')); + assert.equal(await page.getByText('Button Builder failed to load:', { exact: false }).count(), 0); + await page.getByRole('button', { name: 'Switch to light mode' }).click(); + assert.equal(await page.locator('html.bb-light').count(), 1, 'Theme toggle must apply light mode'); + console.log('Browser smoke passed: editor, preview, no runtime crash, theme toggle.'); + } finally { + if (browser) await browser.close(); + server.kill('SIGTERM'); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); From 612a3cce0c74d775b20c483094b83ccd67e7ef65 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:38:00 -0600 Subject: [PATCH 03/11] ci: run Chromium browser smoke test for frontend changes --- .github/workflows/browser-smoke.yml | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/browser-smoke.yml diff --git a/.github/workflows/browser-smoke.yml b/.github/workflows/browser-smoke.yml new file mode 100644 index 0000000..c3d6a18 --- /dev/null +++ b/.github/workflows/browser-smoke.yml @@ -0,0 +1,40 @@ +name: Browser smoke + +on: + pull_request: + paths: + - "src/**" + - "tests/browserSmoke.cjs" + - "package.json" + - "package-lock.json" + - "vite.config.ts" + - ".github/workflows/browser-smoke.yml" + push: + branches: [main] + paths: + - "src/**" + - "tests/browserSmoke.cjs" + - "package.json" + - "package-lock.json" + - "vite.config.ts" + - ".github/workflows/browser-smoke.yml" + +permissions: + contents: read + +jobs: + browser-smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - name: Install pinned browser test tooling + run: | + npm install --no-save --package-lock=false playwright@1.56.1 + npx playwright install --with-deps chromium + - run: node tests/browserSmoke.cjs From a1f02db6b8e95ad06d3f71a7370a30e13a980f78 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:38:54 -0600 Subject: [PATCH 04/11] fix: quote custom-field style values in YAML output --- src/utils/yamlGenerator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/yamlGenerator.ts b/src/utils/yamlGenerator.ts index 58cb5f3..ce8d86e 100644 --- a/src/utils/yamlGenerator.ts +++ b/src/utils/yamlGenerator.ts @@ -1438,7 +1438,7 @@ export const generateYaml = ( if (field.styles) { field.styles.split('\n').forEach(line => { if (line.trim()) { - yaml += ` - ${line.trim()}\n`; + yaml += ` - ${formatStyleForYaml(line.trim())}\n`; } }); } From cce0e0400b7a618aea6b0771e763dc0eff0bc6c4 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:38:56 -0600 Subject: [PATCH 05/11] fix: import custom-field CSS style mapping values --- src/utils/yamlImporter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/yamlImporter.ts b/src/utils/yamlImporter.ts index b1bd2fb..ee3f999 100644 --- a/src/utils/yamlImporter.ts +++ b/src/utils/yamlImporter.ts @@ -693,7 +693,13 @@ function parseStyles(styles: any, config: Partial): void { for (const field of config.customFields) { if (styles.custom_fields[field.name] && Array.isArray(styles.custom_fields[field.name])) { field.styles = styles.custom_fields[field.name] - .filter((s: any) => typeof s === 'string') + .flatMap((style: any) => { + if (typeof style === 'string') return [style]; + if (style && typeof style === 'object' && !Array.isArray(style)) { + return Object.entries(style).map(([property, value]) => `${property}: ${String(value ?? '')}`); + } + return []; + }) .join('\n'); } } From b001128f62492b608d8cd3de27e970dd782d5edd Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:39:38 -0600 Subject: [PATCH 06/11] test: assert native button-card YAML style mappings --- tests/yamlRoundTrip.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/yamlRoundTrip.test.cjs b/tests/yamlRoundTrip.test.cjs index 8b5cf99..8ec0b6a 100644 --- a/tests/yamlRoundTrip.test.cjs +++ b/tests/yamlRoundTrip.test.cjs @@ -97,7 +97,7 @@ test('preserves template custom fields and their style declarations', () => { }); assert.equal(imported.customFields[0].type, 'template'); assert.equal(parsed.custom_fields.status.trim(), value); - assert.deepEqual(parsed.styles.custom_fields.status, ['color: #33cc66', 'font-weight: bold']); + assert.deepEqual(parsed.styles.custom_fields.status, [{ color: '#33cc66' }, { 'font-weight': 'bold' }]); }); test('retains a regex state override with its icon and label', () => { From d137abcf26dfec7be94b98bbd4252b0f75054bf7 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:39:55 -0600 Subject: [PATCH 07/11] test: terminate Vite directly after Chromium smoke check --- tests/browserSmoke.cjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs index 0369049..940abaa 100644 --- a/tests/browserSmoke.cjs +++ b/tests/browserSmoke.cjs @@ -5,9 +5,8 @@ const { spawn } = require('node:child_process'); const { chromium } = require('playwright'); const address = 'http://127.0.0.1:4173/button_builder/'; -const server = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], { +const server = spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], { stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', }); let logs = ''; server.stdout.on('data', chunk => { logs += chunk.toString(); }); From 39df5a3b5b54f87c6b1092c334e61a638d0926b7 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:41:34 -0600 Subject: [PATCH 08/11] test: locate preview toolbar independently of entity text --- tests/browserSmoke.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs index 940abaa..47d8984 100644 --- a/tests/browserSmoke.cjs +++ b/tests/browserSmoke.cjs @@ -34,7 +34,7 @@ async function ready() { page.on('pageerror', error => errors.push(error.message)); await page.goto(address, { waitUntil: 'domcontentloaded' }); await page.getByRole('heading', { name: 'Button Card Builder' }).waitFor({ timeout: 20000 }); - await page.getByText('Live Preview', { exact: true }).first().waitFor({ timeout: 15000 }); + await page.locator('.preview-workbench .preview-toolbar').first().waitFor({ timeout: 15000 }); assert.equal(errors.length, 0, 'Uncaught browser exceptions: ' + errors.join('; ')); assert.equal(await page.getByText('Button Builder failed to load:', { exact: false }).count(), 0); await page.getByRole('button', { name: 'Switch to light mode' }).click(); From 0a929ee370cf7c050ec5729693786315cb3a1eb9 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:43:42 -0600 Subject: [PATCH 09/11] test: dismiss first-run requirements dialog before theme check --- tests/browserSmoke.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs index 47d8984..ae1541f 100644 --- a/tests/browserSmoke.cjs +++ b/tests/browserSmoke.cjs @@ -37,6 +37,12 @@ async function ready() { await page.locator('.preview-workbench .preview-toolbar').first().waitFor({ timeout: 15000 }); assert.equal(errors.length, 0, 'Uncaught browser exceptions: ' + errors.join('; ')); assert.equal(await page.getByText('Button Builder failed to load:', { exact: false }).count(), 0); + // The standalone preview can show the first-run requirements dialog. + // Dismiss it through the same Continue control a user would press. + const requirements = page.getByRole('heading', { name: 'Home Assistant Requirements Check' }); + if (await requirements.isVisible()) { + await page.getByRole('button', { name: 'Continue' }).click(); + } await page.getByRole('button', { name: 'Switch to light mode' }).click(); assert.equal(await page.locator('html.bb-light').count(), 1, 'Theme toggle must apply light mode'); console.log('Browser smoke passed: editor, preview, no runtime crash, theme toggle.'); From a5101e5adf3b38a42a10d9b84022c73ababf0de5 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:46:49 -0600 Subject: [PATCH 10/11] test: await asynchronous first-run requirements dialog before clicking controls --- tests/browserSmoke.cjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs index ae1541f..0006241 100644 --- a/tests/browserSmoke.cjs +++ b/tests/browserSmoke.cjs @@ -40,6 +40,9 @@ async function ready() { // The standalone preview can show the first-run requirements dialog. // Dismiss it through the same Continue control a user would press. const requirements = page.getByRole('heading', { name: 'Home Assistant Requirements Check' }); + await requirements.waitFor({ state: 'visible', timeout: 5000 }).catch(error => { + if (error.name !== 'TimeoutError') throw error; + }); if (await requirements.isVisible()) { await page.getByRole('button', { name: 'Continue' }).click(); } From 4e2ffdf04fbfe87d36e914a9cdcc7b4c4af305c8 Mon Sep 17 00:00:00 2001 From: aspenrt78 <105655892+aspenrt78@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:49:18 -0600 Subject: [PATCH 11/11] test: complete first-run theme setup before browser interaction --- tests/browserSmoke.cjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs index 0006241..6236d36 100644 --- a/tests/browserSmoke.cjs +++ b/tests/browserSmoke.cjs @@ -37,6 +37,11 @@ async function ready() { await page.locator('.preview-workbench .preview-toolbar').first().waitFor({ timeout: 15000 }); assert.equal(errors.length, 0, 'Uncaught browser exceptions: ' + errors.join('; ')); assert.equal(await page.getByText('Button Builder failed to load:', { exact: false }).count(), 0); + // On a fresh browser profile, Button Builder first asks about theme defaults. + const themeDialog = page.getByRole('heading', { name: 'Theme options' }); + if (await themeDialog.isVisible()) { + await page.getByRole('button', { name: 'Start with no theme' }).click(); + } // The standalone preview can show the first-run requirements dialog. // Dismiss it through the same Continue control a user would press. const requirements = page.getByRole('heading', { name: 'Home Assistant Requirements Check' });