From 448054bbc5b8f738109f053765332bff69e4a0d3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 31 Oct 2026 09:59:58 -0400 Subject: [PATCH 1/2] fix(web): preserve the first zone checkbox toggle --- web/js/components/preact/ZoneEditor.jsx | 10 +- web/tests/e2e/zoneEditorCheckbox.spec.js | 178 +++++++++++++++++++++++ 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 web/tests/e2e/zoneEditorCheckbox.spec.js diff --git a/web/js/components/preact/ZoneEditor.jsx b/web/js/components/preact/ZoneEditor.jsx index 7ef988c65..439ebb34f 100644 --- a/web/js/components/preact/ZoneEditor.jsx +++ b/web/js/components/preact/ZoneEditor.jsx @@ -641,7 +641,10 @@ export function ZoneEditor({ streamName, streamWidth, streamHeight, zones = [], className={`mb-3 p-3 rounded border ${ index === selectedZoneIndex ? 'border-primary bg-primary/10' : 'border-border' }`} - onClick={() => setSelectedZoneIndex(index)} + onClick={(event) => { + // Preserve the native toggle until onChange records it below. + if (event.target.type !== 'checkbox') setSelectedZoneIndex(index); + }} > updateZoneProperty(index, 'enabled', e.target.checked)} + onChange={(e) => { + updateZoneProperty(index, 'enabled', e.target.checked); + setSelectedZoneIndex(index); + }} /> {t('common.enabled')} diff --git a/web/tests/e2e/zoneEditorCheckbox.spec.js b/web/tests/e2e/zoneEditorCheckbox.spec.js new file mode 100644 index 000000000..26104885f --- /dev/null +++ b/web/tests/e2e/zoneEditorCheckbox.spec.js @@ -0,0 +1,178 @@ +/** First activation must preserve the native checkbox value while selecting its zone. */ +const path = require('node:path'); +const fixtureZones = [{ + id: 'one', + name: 'Entry', + enabled: true, + color: '#3b82f6', + polygon: [{ + x: .1, + y: .1 + }, { + x: .4, + y: .1 + }, { + x: .2, + y: .4 + }] +}]; +const t = key => 'translated:' + key; +// Run with: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox +const browserTest = it; +async function openZoneBrowser() { + const { + chromium + } = require('../../../node_modules/playwright'); + const { + build + } = require('esbuild'); + const webRoot = path.resolve(__dirname, '../..'); + const output = await build({ + stdin: { + resolveDir: webRoot, + loader: 'jsx', + contents: ` + import {h,render} from 'preact'; + import {ZoneEditor} from './js/components/preact/ZoneEditor.jsx'; + const zones=${JSON.stringify(fixtureZones)}; + window.calls={saved:[],closed:0,events:[],requests:[]}; + window.fetch=async(url,options={})=>{if(options.method==='POST'){window.calls.requests.push({url,method:options.method,body:JSON.parse(options.body)});return{ok:true,json:async()=>({})};}return{ok:true,json:async()=>{window.zonesLoaded=true;return{zones};}};}; + window.mount=()=>render(h(ZoneEditor,{streamName:'Entry',streamWidth:640,streamHeight:360,zones,onClose:()=>window.calls.closed++,onZonesChange:value=>window.calls.saved.push(value)}),document.getElementById('root')); + for(const capture of[true,false])for(const type of['click','input','change'])document.addEventListener(type,event=>{if(event.target.type==='checkbox')window.calls.events.push({type,capture,checked:event.target.checked,selected:String(document.querySelector('input[type=checkbox]')?.closest('div.mb-3.p-3.rounded.border')?.classList.contains('border-primary'))});},capture); + ` + }, + bundle: true, + write: false, + format: 'iife', + jsx: 'automatic', + jsxImportSource: 'preact', + plugins: [{ + name: 'fixture', + setup(builder) { + builder.onResolve({ + filter: /(?:i18n|settings-utils)\.js$/ + }, args => ({ + path: args.path, + namespace: 'fixture' + })); + builder.onLoad({ + filter: /.*/, + namespace: 'fixture' + }, () => ({ + contents: "export const t=key=>'translated:'+key;export const useI18n=()=>({t});export const getGo2rtcBaseUrl=async()=>'/go2rtc';", + loader: 'js' + })); + } + }] + }); + const browser = await chromium.launch({ + headless: true + }); + const page = await browser.newPage({ + viewport: { + width: 1200, + height: 1000 + } + }); + page.setDefaultTimeout(5000); + await page.route('http://fixture.invalid/**', route => route.fulfill({ + contentType: route.request().url() === 'http://fixture.invalid/' ? 'text/html' : 'image/svg+xml', + body: route.request().url() === 'http://fixture.invalid/' ? '
' : '' + })); + await page.goto('http://fixture.invalid/'); + // Standalone fixture layout: no shared build assets, server or operator state. + await page.addStyleTag({ + content: 'canvas{max-width:640px;max-height:360px}button,input{margin:4px}' + }); + await page.addScriptTag({ + content: output.outputFiles[0].text + }); + await page.evaluate(() => window.mount()); + try { + await page.waitForFunction(() => window.zonesLoaded && document.querySelector('input[type=checkbox]'), {}, { + timeout: 5000 + }); + } catch (error) { + await browser.close(); + throw error; + } + return { + browser, + page + }; +} +browserTest.each([['pointer', false], ['keyboard', false], ['label', false], ['pointer', true], ['keyboard', true], ['label', true]])('first zone checkbox activation preserves value and exact save (method=%s, preselect=%s)', async (method, preselect) => { + const { + browser, + page + } = await openZoneBrowser(); + try { + const checkbox = page.getByRole('checkbox', { + name: t('common.enabled'), + exact: true + }); + const card = page.locator('div.mb-3.p-3.rounded.border').first(); + const selectedState = () => card.evaluate(element => String(element.classList.contains('border-primary'))); + expect(await checkbox.isChecked()).toBe(true); + expect(await selectedState()).toBe('false'); + if (preselect) await card.locator('input[type=text]').click(); + const activate = async () => { + if (method === 'pointer') await checkbox.click();else if (method === 'label') await checkbox.locator('..').locator('span').click();else { + // Reach the checkbox solely through native Tab navigation; never click a field/card. + for (let index = 0; index < 20 && !(await checkbox.evaluate(element => element === document.activeElement)); index++) await page.keyboard.press('Tab'); + expect(await checkbox.evaluate(element => element === document.activeElement)).toBe(true); + await page.keyboard.press('Space'); + } + }; + await activate(); + const firstChecked = await checkbox.isChecked(); + expect(await checkbox.evaluate(element => element === document.activeElement)).toBe(true); + await activate(); + await activate(); + const checked = await checkbox.isChecked(); + const selected = await selectedState(); + await page.getByRole('button', { + name: t('zoneEditor.saveZones'), + exact: true + }).click(); + await page.waitForFunction(() => window.calls.saved.length === 1); + const calls = await page.evaluate(() => window.calls); + if (checked !== false) console.log('ZONE_FIRST_TOGGLE_TRACE', JSON.stringify({ + method, + preselect, + checked, + selected, + calls + })); + expect(calls.events.filter(event => event.type === 'change' && !event.capture)).toHaveLength(3); + expect({ + firstChecked, + checked, + selected, + saved: calls.saved, + closed: calls.closed, + requests: calls.requests + }).toEqual({ + firstChecked: false, + checked: false, + selected: 'true', + saved: [[{ + ...fixtureZones[0], + enabled: false + }]], + closed: 1, + requests: [{ + url: '/api/streams/Entry/zones', + method: 'POST', + body: { + zones: [{ + ...fixtureZones[0], + enabled: false + }] + } + }] + }); + } finally { + await browser.close(); + } +}, 30000); From 37c5b5c87684dd25e5855fa8e8b793c9d95f9bfc Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 31 Oct 2026 10:03:50 -0400 Subject: [PATCH 2/2] ci: run the zone checkbox regression on the first shard --- .github/workflows/integration-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f4f825289..56ec95657 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -604,6 +604,10 @@ jobs: npm ci npx playwright install --with-deps chromium + - name: Zone Editor checkbox regression + if: ${{ matrix.shard == 1 }} + run: npm --prefix web run test:e2e -- --testPathPatterns=zoneEditorCheckbox + - name: Run Playwright UI tests run: | # Clean any existing playwright reports to ensure fresh state