Files
meshcore-analyzer/test-drag-manager-e2e.js
T
Kpa-clawbot 8e86997ac6 test(coverage): add Playwright E2E for customizer + drag-manager (#1297 B4) (#1304)
## Summary

Adds **Playwright E2E coverage** for the B4 customizer batch under
umbrella issue #1297.
Files in scope:
- `public/customize-v2.js` (1774 LOC, largest under-tested surface)
- `public/drag-manager.js` (216 LOC)

## New test suites

| Suite | What it covers |
|------|---------------|
| `test-customize-theme-e2e.js` | Theme tab: preset clicks, color picker
→ CSS variable assertion (THEME_CSS_MAP invariant — colors via
`--accent` not inline styles), `cs-theme-overrides` localStorage write,
cross-reload persistence |
| `test-customize-branding-e2e.js` | Branding tab: `siteName` live
updates `document.title`, `logoUrl` swaps inline SVG → `<img>` via
`_setBrandLogoUrl()` helper (PR #1137), persistence |
| `test-customize-display-e2e.js` | Display + Nodes tabs: `distanceUnit`
scalar, `timestamps.defaultMode` nested override, heatmap opacity slider
writes `0.75`, node-role color picker, full persistence |
| `test-customize-export-e2e.js` | Export tab: raw JSON textarea
reflects current state, Download button wired, `Reset All` clears
overrides + reverts inline CSS variables |
| `test-drag-manager-e2e.js` | Real Playwright `mouse.down/move/up` drag
on `#liveFeed .panel-header`: `data-position` removed,
`data-dragged="true"` set, `panel-drag-liveFeed` localStorage has
`xPct/yPct`, restored on reload; dead-zone click (≤5px) does NOT persist
|

Each suite asserts the customizer writes **CSS variables on
`document.documentElement.style`** (not inline element styles) —
preserves the "all colors via CSS variables" invariant required by
AGENTS.md.

## TDD evidence

- `ff8e1da1` — **RED**: theme suite contains a sentinel assertion
(`window._customizerV2.RED_SENTINEL_DO_NOT_ADD ===
'B4_CUSTOMIZER_COVERAGE_GREEN'`) that fails on assertion (not import
error), proving the suite executes and gates behavior.
- `30576593` — **GREEN**: sentinel removed, all five suites wired into
`.github/workflows/deploy.yml` so they participate in CI gating +
aggregated PASS/FAIL count.

Local run against a freshened fixture (`/tmp/e2e.db`) confirms **36/36
tests pass** across the five suites.

## Preflight overrides

`check-branch-clean.sh` flagged "diff spans 6 top-level dirs" — false
positive. The diff is exactly:
- `.github/workflows/deploy.yml` (CI wiring)
- 5 `test-customize-*-e2e.js` / `test-drag-manager-e2e.js` files at repo
root

The script's heuristic counts each root-level test file as a separate
"top-level dir" via `awk -F/ '{print $1}'`. All other gates pass (PII,
red commit, CSS-var defined, CSS self-fallback, LIKE-on-JSON, sync
migration, img/SVG, themed `<img>` SVG, fixture coverage).

Refs #1297

---------

Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-20 18:57:17 -07:00

147 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E (#1297 B4): drag-manager.js — Real Playwright pointer drag on a live panel
*
* Exercises public/drag-manager.js via the /live route which registers
* #liveFeed, #liveLegend, #liveNodeDetail panels. We use Playwright's pointer
* API (mouse.move/down/up) to simulate a drag, and assert:
* - data-position attribute is removed (free-form mode engaged)
* - data-dragged='true' is set
* - panel-drag-{id} localStorage entry contains xPct/yPct
* - Restoring on reload re-applies the position
*
* Usage: BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
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'],
});
// Desktop viewport: drag is disabled below 768px or on coarse pointer
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1297 B4 drag-manager E2E against ${BASE} ===`);
await step('navigate to /live', async () => {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
// Clear any prior drag positions
await page.evaluate(() => {
['liveFeed', 'liveLegend', 'liveNodeDetail'].forEach(id => {
try { localStorage.removeItem('panel-drag-' + id); } catch (_) {}
});
});
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#liveFeed .panel-header', { timeout: 8000 });
});
await step('DragManager is initialized', async () => {
const hasDM = await page.evaluate(() => typeof window.DragManager === 'function');
assert(hasDM, 'window.DragManager should be defined');
});
await step('pointer drag on #liveFeed transitions panel into free-form mode', async () => {
// Locate the panel-header rect
const headerBox = await page.$eval('#liveFeed .panel-header', el => {
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
});
// Real mouse drag — outside the dead zone (5px)
await page.mouse.move(headerBox.x, headerBox.y);
await page.mouse.down();
await page.mouse.move(headerBox.x + 120, headerBox.y + 80, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(200);
const state = await page.$eval('#liveFeed', el => ({
dragged: el.dataset.dragged,
position: el.getAttribute('data-position'),
left: el.style.left,
top: el.style.top,
}));
assert(state.dragged === 'true', 'data-dragged should be "true", got: ' + JSON.stringify(state));
assert(state.position === null, 'data-position should be removed, got: ' + state.position);
assert(state.left && state.left.endsWith('px'), 'inline left should be px value, got: ' + state.left);
});
await step('panel-drag-liveFeed localStorage entry written with xPct/yPct', async () => {
const raw = await page.evaluate(() => localStorage.getItem('panel-drag-liveFeed'));
assert(raw, 'panel-drag-liveFeed should be written');
const parsed = JSON.parse(raw);
assert(typeof parsed.xPct === 'number' && parsed.xPct >= 0 && parsed.xPct <= 1,
'xPct should be in [0,1], got: ' + raw);
assert(typeof parsed.yPct === 'number' && parsed.yPct >= 0 && parsed.yPct <= 1,
'yPct should be in [0,1], got: ' + raw);
});
await step('reload restores panel to dragged position via restorePositions()', async () => {
const savedRaw = await page.evaluate(() => localStorage.getItem('panel-drag-liveFeed'));
const saved = JSON.parse(savedRaw);
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#liveFeed .panel-header', { timeout: 8000 });
await page.waitForTimeout(500); // restorePositions runs after init
const restored = await page.$eval('#liveFeed', el => ({
dragged: el.dataset.dragged,
left: el.style.left,
top: el.style.top,
}));
assert(restored.dragged === 'true', 'data-dragged should persist as "true" after reload, got: ' + JSON.stringify(restored));
// Expected px from saved xPct/yPct × viewport
const expectedLeft = Math.round(saved.xPct * 1280);
const actualLeft = parseInt(restored.left, 10);
assert(Math.abs(actualLeft - expectedLeft) <= 5,
'restored left ~' + expectedLeft + 'px, got ' + restored.left);
});
await step('click without movement (within dead zone) does NOT engage drag', async () => {
// Use a fresh panel to avoid prior state
await page.evaluate(() => localStorage.removeItem('panel-drag-liveLegend'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#liveLegend .panel-header', { timeout: 8000 });
const headerBox = await page.$eval('#liveLegend .panel-header', el => {
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
});
await page.mouse.move(headerBox.x, headerBox.y);
await page.mouse.down();
await page.mouse.move(headerBox.x + 2, headerBox.y + 2); // inside DEAD_ZONE (5px)
await page.mouse.up();
await page.waitForTimeout(200);
const raw = await page.evaluate(() => localStorage.getItem('panel-drag-liveLegend'));
assert(raw === null, 'dead-zone click should NOT persist a position, got: ' + raw);
const dragged = await page.$eval('#liveLegend', el => el.dataset.dragged);
assert(dragged !== 'true', 'data-dragged should not be set for dead-zone click, got: ' + dragged);
});
await step('cleanup', async () => {
await page.evaluate(() => {
['liveFeed', 'liveLegend', 'liveNodeDetail'].forEach(id => {
try { localStorage.removeItem('panel-drag-' + id); } catch (_) {}
});
});
});
await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
process.exit(failed > 0 ? 1 : 0);
})();