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 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`; } }); } 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'); } } diff --git a/tests/browserSmoke.cjs b/tests/browserSmoke.cjs new file mode 100644 index 0000000..6236d36 --- /dev/null +++ b/tests/browserSmoke.cjs @@ -0,0 +1,61 @@ +'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(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '4173', '--strictPort'], { + stdio: ['ignore', 'pipe', 'pipe'], +}); +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.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' }); + 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(); + } + 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; }); diff --git a/tests/yamlRoundTrip.test.cjs b/tests/yamlRoundTrip.test.cjs index 5bb7b58..8ec0b6a 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); +});