mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 22:41:23 +00:00
6d5c731d2e
## Summary
Master CI has been failing on `test-channel-color-picker-e2e.js` — the
"outside click closes popover" step — most recently on run
[26574358472](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472)
(master push `d24246395`). The previous deflake attempt (#1317, commit
62a81776) only papered over part of the race.
## Root cause
`showPopover` in `public/channel-color-picker.js:148-152` installs the
document-level outside-click listener inside a `setTimeout(0)`:
```js
setTimeout(function() {
document.addEventListener('click', onOutsideClick, true);
document.addEventListener('keydown', onEscape, true);
}, 0);
```
The previous fix tried to wait for that listener with a `rect.width > 0`
"popover visible" proxy — but visibility ≠ listener install. Under CI
load, the macrotask can be deferred past Playwright's polling
resolution, so `page.mouse.click(700, 500)` fires before the listener
exists, the click is dropped, and the second `waitForFunction` runs out
the 8s default timeout.
## Fix (test-only)
1. **Drain pending macrotasks node-side** with `requestAnimationFrame` ×
2 + `setTimeout(0)` before clicking, so the same scheduler tier the
listener uses has definitely run.
2. **Retry the outside click in a small loop** (up to 10×, 1s each).
Even if the very first synthetic click still races install, subsequent
clicks land cleanly. Each retry is cheap (~ms), and `assert(closed,
...)` gives a clear failure message if the popover never hides.
## Verification
| Scenario | Old test | New test |
|---|---|---|
| Baseline (no artificial delay) | passes | 45/45 clean runs locally |
| Artificially delay listener install to **250ms** | **5/5 FAIL** | 5/5
PASS (popover closes on retry #2) |
Production code untouched. Comment block in-test captures the history so
the next person doesn't re-introduce the race.
## Linked
- Supersedes the partial fix in #1317
- CI run that exposed it:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26574358472
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
224 lines
9.9 KiB
JavaScript
224 lines
9.9 KiB
JavaScript
/**
|
||
* #1297 B2 — Coverage E2E for public/channel-color-picker.js
|
||
*
|
||
* Exercises the picker popover by driving its public API
|
||
* (window.ChannelColorPicker.show / .hide) on the /#/channels page and
|
||
* asserting:
|
||
* - palette renders all 8 swatches
|
||
* - clicking a swatch writes ChannelColors.set + updates .ch-color-dot
|
||
* - Escape closes popover
|
||
* - keyboard ArrowRight cycles focus across swatches
|
||
* - Clear button removes the assignment (when one exists)
|
||
* - active-class highlights the currently assigned color
|
||
*
|
||
* Usage: BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js
|
||
*/
|
||
'use strict';
|
||
const { chromium } = require('playwright');
|
||
|
||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||
|
||
let passed = 0, failed = 0;
|
||
async function step(name, fn) {
|
||
try { await fn(); passed++; console.log(' \u2713 ' + name); }
|
||
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
|
||
}
|
||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||
|
||
(async () => {
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||
});
|
||
const ctx = await browser.newContext();
|
||
const page = await ctx.newPage();
|
||
page.setDefaultTimeout(8000);
|
||
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||
|
||
console.log('\n=== #1297 B2 channel-color-picker E2E against ' + BASE + ' ===');
|
||
|
||
// Bootstrap: load page, clear storage
|
||
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#chList', { timeout: 8000 });
|
||
await page.evaluate(() => {
|
||
try { localStorage.removeItem('live-channel-colors'); } catch (e) {}
|
||
});
|
||
|
||
await step('window.ChannelColorPicker is loaded with PALETTE', async () => {
|
||
const palette = await page.evaluate(() =>
|
||
window.ChannelColorPicker && window.ChannelColorPicker.PALETTE);
|
||
assert(Array.isArray(palette), 'PALETTE missing');
|
||
assert(palette.length === 8, 'expected 8 colors, got ' + palette.length);
|
||
assert(palette[0] === '#ef4444', 'first color should be #ef4444, got ' + palette[0]);
|
||
});
|
||
|
||
await step('show() opens popover with 8 swatches', async () => {
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#testchan', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover', { timeout: 3000 });
|
||
const swatchCount = await page.$$eval('.cc-swatch', els => els.length);
|
||
assert(swatchCount === 8, 'expected 8 swatches, got ' + swatchCount);
|
||
const visible = await page.evaluate(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display !== 'none';
|
||
});
|
||
assert(visible, 'popover should be visible after show()');
|
||
});
|
||
|
||
await step('Escape closes popover', async () => {
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForFunction(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display === 'none';
|
||
}, { timeout: 3000 });
|
||
});
|
||
|
||
await step('clicking a swatch writes ChannelColors + closes popover', async () => {
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#myroom', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover');
|
||
// Click the green swatch (#22c55e)
|
||
await page.click('.cc-swatch[data-color="#22c55e"]');
|
||
// Popover should hide
|
||
await page.waitForFunction(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display === 'none';
|
||
}, { timeout: 3000 });
|
||
const stored = await page.evaluate(() =>
|
||
window.ChannelColors && window.ChannelColors.get('#myroom'));
|
||
assert(stored === '#22c55e', 'expected stored color #22c55e, got ' + stored);
|
||
const raw = await page.evaluate(() =>
|
||
localStorage.getItem('live-channel-colors'));
|
||
assert(raw && raw.indexOf('#22c55e') >= 0,
|
||
'expected localStorage live-channel-colors to contain #22c55e, got: ' + raw);
|
||
});
|
||
|
||
await step('reopening picker for assigned channel marks active swatch + shows Clear', async () => {
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#myroom', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover');
|
||
const activeColor = await page.$eval('.cc-swatch.cc-swatch-active',
|
||
el => el.getAttribute('data-color'));
|
||
assert(activeColor === '#22c55e',
|
||
'expected active swatch #22c55e, got ' + activeColor);
|
||
const clearVisible = await page.evaluate(() => {
|
||
const b = document.querySelector('.cc-picker-clear');
|
||
return b && b.style.display !== 'none';
|
||
});
|
||
assert(clearVisible, 'Clear button should be visible when color is assigned');
|
||
});
|
||
|
||
await step('Clear button removes the channel color', async () => {
|
||
await page.click('.cc-picker-clear');
|
||
await page.waitForFunction(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display === 'none';
|
||
}, { timeout: 3000 });
|
||
const stored = await page.evaluate(() =>
|
||
window.ChannelColors && window.ChannelColors.get('#myroom'));
|
||
assert(stored == null,
|
||
'expected color cleared, got ' + JSON.stringify(stored));
|
||
});
|
||
|
||
await step('Clear button is hidden when no color assigned', async () => {
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#freshchan', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover');
|
||
const clearHidden = await page.evaluate(() => {
|
||
const b = document.querySelector('.cc-picker-clear');
|
||
return b && b.style.display === 'none';
|
||
});
|
||
assert(clearHidden, 'Clear button should be hidden when no color assigned');
|
||
await page.keyboard.press('Escape');
|
||
});
|
||
|
||
await step('ArrowRight cycles focus across swatches', async () => {
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#navchan', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover');
|
||
// Wait a tick for setTimeout(0) focus
|
||
await page.waitForFunction(() => {
|
||
const el = document.activeElement;
|
||
return el && el.classList && el.classList.contains('cc-swatch');
|
||
}, { timeout: 2000 });
|
||
const firstColor = await page.evaluate(() =>
|
||
document.activeElement.getAttribute('data-color'));
|
||
await page.keyboard.press('ArrowRight');
|
||
const nextColor = await page.evaluate(() =>
|
||
document.activeElement.getAttribute('data-color'));
|
||
assert(nextColor && nextColor !== firstColor,
|
||
'ArrowRight should move focus to next swatch (was ' + firstColor + ', now ' + nextColor + ')');
|
||
// Enter to assign + close
|
||
await page.keyboard.press('Enter');
|
||
await page.waitForFunction(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display === 'none';
|
||
}, { timeout: 3000 });
|
||
const stored = await page.evaluate(() =>
|
||
window.ChannelColors && window.ChannelColors.get('#navchan'));
|
||
assert(stored === nextColor,
|
||
'Enter should assign focused color (' + nextColor + '), got ' + stored);
|
||
});
|
||
|
||
await step('outside click closes popover', async () => {
|
||
// De-flake history: #1317 (62a81776) tried `mouse.click(700,500)` + a
|
||
// `rect.width > 0` "listener installed" proxy. That proxy is FALSE — it
|
||
// only proves the popover is visible, not that showPopover's
|
||
// `setTimeout(0)` document-level click listener has actually run. Under
|
||
// CI load the macrotask can be deferred past Playwright's polling
|
||
// resolution, so the synthetic click fires BEFORE the listener exists,
|
||
// is dropped, and the popover never hides → 8s default-timeout failure
|
||
// (see run 26574358472 / d24246395 master push).
|
||
//
|
||
// Real fix: (1) install a one-shot probe of our own via
|
||
// `requestAnimationFrame + setTimeout(0)` and `await` it from
|
||
// node-side, guaranteeing showPopover's setTimeout(0) drained;
|
||
// (2) retry the click in a small loop, since even with the probe
|
||
// there's no synchronous handle on Playwright's internal event-loop
|
||
// ordering. Each click is cheap (~ms); the popover hides on the first
|
||
// one that reaches the installed listener.
|
||
await page.evaluate(() =>
|
||
window.ChannelColorPicker.show('#outsidechan', 100, 100));
|
||
await page.waitForSelector('.cc-picker-popover', { state: 'visible', timeout: 5000 });
|
||
// Drain pending macrotasks (showPopover's setTimeout(0) installs the
|
||
// outside-click listener). Wait two animation frames + a setTimeout(0)
|
||
// so the same scheduler tier the listener uses has definitely run.
|
||
await page.evaluate(() => new Promise((r) => {
|
||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||
setTimeout(r, 0)));
|
||
}));
|
||
// Click outside in a retry loop — if the very first synthetic click
|
||
// still races the listener install, subsequent clicks land cleanly.
|
||
// Popover anchored at (100,100); click at (700,500) is unambiguously
|
||
// outside its bounding rect (popover is ~300×80).
|
||
const closed = await (async () => {
|
||
for (let i = 0; i < 10; i++) {
|
||
await page.mouse.click(700, 500);
|
||
try {
|
||
await page.waitForFunction(() => {
|
||
const el = document.querySelector('.cc-picker-popover');
|
||
return el && el.style.display === 'none';
|
||
}, { timeout: 1000 });
|
||
return true;
|
||
} catch (_) {
|
||
// Re-check listener install by waiting another rAF and retrying.
|
||
await page.evaluate(() => new Promise((r) =>
|
||
requestAnimationFrame(() => setTimeout(r, 0))));
|
||
}
|
||
}
|
||
return false;
|
||
})();
|
||
assert(closed, 'popover did not close after 10 outside-click attempts');
|
||
});
|
||
|
||
// Cleanup
|
||
await page.evaluate(() => {
|
||
try { localStorage.removeItem('live-channel-colors'); } catch (e) {}
|
||
});
|
||
|
||
console.log('\n=== Results: passed ' + passed + ' failed ' + failed + ' ===');
|
||
await browser.close();
|
||
process.exit(failed > 0 ? 1 : 0);
|
||
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
|