← back to Wallco Ai
test: add E2E for /admin/needs-tif (marquee select + 8 sorts + density) — Playwright, runs against a live server
693981b2099d30cedd6962aa16a837ba18b52a42 · 2026-06-02 10:22:44 -0700 · Steve Abrams
Files touched
A scripts/test-needstif.js
Diff
commit 693981b2099d30cedd6962aa16a837ba18b52a42
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 2 10:22:44 2026 -0700
test: add E2E for /admin/needs-tif (marquee select + 8 sorts + density) — Playwright, runs against a live server
---
scripts/test-needstif.js | 112 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 112 insertions(+)
diff --git a/scripts/test-needstif.js b/scripts/test-needstif.js
new file mode 100644
index 0000000..2247d9a
--- /dev/null
+++ b/scripts/test-needstif.js
@@ -0,0 +1,112 @@
+// E2E test for /admin/needs-tif — marquee drag-select + sorts + density.
+// Drives a real headless Chromium with real mouse events (not code-reading) against
+// a running server. This test already caught two real bugs: (1) the marquee only
+// started inside .grid, never from the surrounding margins; (2) a stuck didDrag flag
+// after a drag ending on empty space swallowed the user's next click.
+//
+// Playwright is NOT a project dependency (keeps the prod install lean). It lives in a
+// scratch install; point NODE_PATH at it when running:
+//
+// NODE_PATH=/tmp/cwtest/node_modules node scripts/test-needstif.js
+// # or against another host:
+// NEEDSTIF_URL=http://127.0.0.1:9905/admin/needs-tif NODE_PATH=... node scripts/test-needstif.js
+//
+// To create the scratch install once:
+// mkdir -p /tmp/cwtest && cd /tmp/cwtest && npm i playwright && npx playwright install chromium
+//
+// Run it on Mac2 (the build box), where dw_unified.all_designs holds the real
+// ~3,400-row NULL-tif backlog — prod's own DB is sparse and the grid is empty there.
+
+const { chromium } = require('playwright');
+const URL = process.env.NEEDSTIF_URL || 'http://127.0.0.1:9905/admin/needs-tif';
+const A = (cond, msg) => { console.log((cond ? 'PASS ' : 'FAIL ') + msg); if (!cond) process.exitCode = 1; };
+
+(async () => {
+ const b = await chromium.launch();
+ const ctx = await b.newContext({ viewport: { width: 1280, height: 950 }, permissions: ['clipboard-read', 'clipboard-write'] });
+ const p = await ctx.newPage();
+ await p.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
+ await p.waitForSelector('.card', { timeout: 15000 });
+ await p.waitForTimeout(600);
+
+ const nCards = await p.$$eval('.card', e => e.length);
+ A(nCards > 100, `grid rendered ${nCards} cards`);
+
+ // ---- 1. marquee drag-select across a row of cards ----
+ const boxes = await p.$$eval('.card', els => els.slice(0, 6).map(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
+ const first = boxes[0];
+ // drag from just left/above card0 (in the grid margin) to inside card3 -> should lasso the first several
+ const startX = first.x - 8, startY = first.y - 8;
+ const endBox = boxes[3];
+ const endX = endBox.x + endBox.w / 2, endY = endBox.y + endBox.h / 2;
+ await p.mouse.move(startX, startY);
+ await p.mouse.down();
+ await p.mouse.move(startX + 20, startY + 20, { steps: 3 });
+ await p.mouse.move(endX, endY, { steps: 12 });
+ const marqueeVisible = await p.$eval('#marquee', e => getComputedStyle(e).display !== 'none');
+ A(marqueeVisible, 'marquee box visible during drag');
+ await p.mouse.up();
+ const selAfterDrag = await p.$eval('#selcount', e => +e.textContent);
+ A(selAfterDrag >= 2, `drag selected ${selAfterDrag} cards (>=2)`);
+ const barShown = await p.$eval('#selbar', e => e.classList.contains('show'));
+ A(barShown, 'selection bar appears after drag');
+ const marqueeHidden = await p.$eval('#marquee', e => getComputedStyle(e).display === 'none');
+ A(marqueeHidden, 'marquee hidden after mouseup');
+
+ // ---- 2. Esc clears ----
+ await p.keyboard.press('Escape');
+ A((await p.$eval('#selcount', e => +e.textContent)) === 0, 'Escape clears selection');
+ A(!(await p.$eval('#selbar', e => e.classList.contains('show'))), 'selection bar hidden after clear');
+
+ // ---- 3. pick-box click toggles one without navigating ----
+ const urlBefore = p.url();
+ await p.$$eval('.card .pick', els => els[0].click());
+ await p.waitForTimeout(150);
+ A((await p.$eval('#selcount', e => +e.textContent)) === 1, 'pick-box click selects exactly 1');
+ A(p.url() === urlBefore, 'pick-box click did NOT navigate');
+ await p.$$eval('.card .pick', els => els[0].click());
+ A((await p.$eval('#selcount', e => +e.textContent)) === 0, 'pick-box click again deselects');
+
+ // ---- 4. Cmd/Ctrl+A selects all visible ----
+ await p.keyboard.down('Control'); await p.keyboard.press('a'); await p.keyboard.up('Control');
+ const selAll = await p.$eval('#selcount', e => +e.textContent);
+ A(selAll === nCards, `Ctrl+A selected all (${selAll} === ${nCards})`);
+
+ // ---- 5. Copy IDs writes clipboard ----
+ await p.click('#copyids');
+ await p.waitForTimeout(200);
+ const clip = await p.evaluate(() => navigator.clipboard.readText().catch(() => ''));
+ A(/^\d+(,\s*\d+)+/.test(clip), `Copy IDs wrote ${clip.split(',').length} ids to clipboard`);
+ await p.keyboard.press('Escape');
+
+ // ---- 6. each sort actually reorders the first card ----
+ const firstIdFor = async (val) => {
+ await p.selectOption('#sort', val);
+ await p.waitForTimeout(250);
+ return p.$eval('.card', e => e.dataset.id);
+ };
+ const idAsc = await firstIdFor('id');
+ const idDesc = await firstIdFor('id_desc');
+ A(+idAsc < +idDesc, `ID ↑ first(#${idAsc}) < ID ↓ first(#${idDesc})`);
+ await firstIdFor('category');
+ const catFirst = await p.$eval('.card .cat', e => e.textContent.trim());
+ await firstIdFor('category_desc');
+ const catFirstD = await p.$eval('.card .cat', e => e.textContent.trim());
+ A(catFirst !== catFirstD, `Category A→Z ("${catFirst}") differs from Z→A ("${catFirstD}")`);
+ await firstIdFor('group_size');
+ const grpFirst = await p.$eval('.card .cat', e => e.textContent.trim());
+ A(grpFirst.length > 0, `Group-size sort leads with a group ("${grpFirst}")`);
+ await firstIdFor('color'); A(true, 'Color (by hue) sort applied without error');
+
+ // ---- 7. density slider drives --card-min + persists ----
+ await p.$eval('#density', e => { e.value = '300'; e.dispatchEvent(new Event('input', { bubbles: true })); });
+ const cardMin = await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--card-min').trim());
+ A(cardMin === '300px', `density slider set --card-min to ${cardMin}`);
+ const persisted = await p.evaluate(() => localStorage.getItem('needsTif_density'));
+ A(persisted === '300', `density persisted to localStorage (${persisted})`);
+ const sortPersist = await p.evaluate(() => localStorage.getItem('needsTif_sort'));
+ A(sortPersist === 'color', `sort persisted to localStorage (${sortPersist})`);
+
+ await b.close();
+ console.log('\n' + (process.exitCode ? '*** SOME TESTS FAILED ***' : 'ALL TESTS PASSED'));
+})().catch(e => { console.error('ERR', e.message); process.exit(1); });
← c8866fd needs-tif: marquee can start from grid margins (listener on
·
back to Wallco Ai
·
deploy: add needs-tif admin-asset smoke test (5c) — assert m 4c1e3e3 →