[object Object]

← 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

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 →