[object Object]

← back to Dw Domain Fleet

test(catalog): keep the TK-11470 sort proof harness in-repo

b757902c4173f012d2aae868e853f6d9b962d8cd · 2026-09-11 13:27:52 -0700 · Steve Abrams

Negative-tested: 0/40 on the pre-fix baseline, 40/40 on the fix, so it is a real
detector rather than a rubber stamp. README covers the HTTPS-front requirement
(WebKit honours upgrade-insecure-requests on loopback) and the need to re-capture
modes.json whenever data/catalog.json changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit b757902c4173f012d2aae868e853f6d9b962d8cd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 13:27:52 2026 -0700

    test(catalog): keep the TK-11470 sort proof harness in-repo
    
    Negative-tested: 0/40 on the pre-fix baseline, 40/40 on the fix, so it is a real
    detector rather than a rubber stamp. README covers the HTTPS-front requirement
    (WebKit honours upgrade-insecure-requests on loopback) and the need to re-capture
    modes.json whenever data/catalog.json changes.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 scripts/tk11470-sort-proof/README.md    |  44 +++++++
 scripts/tk11470-sort-proof/modes.json   |   1 +
 scripts/tk11470-sort-proof/proof2.cjs   | 212 ++++++++++++++++++++++++++++++++
 scripts/tk11470-sort-proof/tlsfront.cjs |   8 ++
 4 files changed, 265 insertions(+)

diff --git a/scripts/tk11470-sort-proof/README.md b/scripts/tk11470-sort-proof/README.md
new file mode 100644
index 0000000..2843bae
--- /dev/null
+++ b/scripts/tk11470-sort-proof/README.md
@@ -0,0 +1,44 @@
+# TK-11470 — catalog sort proof harness
+
+Negative-tested cross-engine proof that the `/catalog` sort control works.
+Same harness scores **0 PASS / 40 FAIL** on the pre-fix baseline and **40 PASS / 0 FAIL**
+on the fix, so a green here is earned rather than assumed.
+
+## Run
+
+```sh
+# 1. boot the site you want to prove
+SITE=grassclothwallcovering PORT=18471 node server.js &
+
+# 2. front it with HTTPS — REQUIRED. The CSP sets upgrade-insecure-requests, and WebKit
+#    honours it on loopback, so a form GET over plain http fails TLS and every sort check
+#    fails for a reason that has nothing to do with the product.
+UP=127.0.0.1:18471 TLS_PORT=18472 node scripts/tk11470-sort-proof/tlsfront.cjs &
+
+# 3. capture server-side truth, then assert the browser reproduces it
+MODES=$(for s in newest color light-dark dark-light style sku title price-asc price-desc; do :; done; cat scripts/tk11470-sort-proof/modes.json) \
+BASE=https://127.0.0.1:18472 node scripts/tk11470-sort-proof/proof2.cjs
+```
+
+`modes.json` maps each sort mode to the first SKU that mode *should* render. It is
+data-dependent — **re-capture it whenever `data/catalog.json` changes**, or modes whose
+leader moved will fail for a data reason, not a code reason:
+
+```sh
+for s in newest color light-dark dark-light style sku title price-asc price-desc; do
+  curl -sk "https://127.0.0.1:18472/catalog?sort=$s" | grep -oE 'data-sku="[^"]*"' | head -1
+done
+```
+
+## What it asserts (×2 engines)
+
+9 sort modes drive a real navigation AND produce the real server ordering · search query
+retained · page resets to 1 · sort is NOT remembered · dropdown can never label an ordering
+the grid isn't in · no redirect on a bare visit · Back undoes a sort (grid *and* dropdown) ·
+density clamp (two-sided: valid applies, out-of-range rejected) · DOM-clobber via params
+named `submit`/`appendChild` · denied localStorage · zero CSP violations · works with
+`requestSubmit` deleted (old-Safari simulation).
+
+## To prove it still detects
+
+Point `BASE` at a server running `git show b4b8158^:shared/render.js`. Expect 0/40.
diff --git a/scripts/tk11470-sort-proof/modes.json b/scripts/tk11470-sort-proof/modes.json
new file mode 100644
index 0000000..32fd195
--- /dev/null
+++ b/scripts/tk11470-sort-proof/modes.json
@@ -0,0 +1 @@
+{"newest":"GRS-27580","color":"GRS-29170","light-dark":"GRS-26200","dark-light":"GRS-29170","style":"DWQW-61355","sku":"DWAG-381799","title":"GRS-29010","price-asc":"DWQW-59580-Sample","price-desc":"DWQW-61455"}
diff --git a/scripts/tk11470-sort-proof/proof2.cjs b/scripts/tk11470-sort-proof/proof2.cjs
new file mode 100644
index 0000000..f0c86e6
--- /dev/null
+++ b/scripts/tk11470-sort-proof/proof2.cjs
@@ -0,0 +1,212 @@
+const PW='/Users/macstudio3/Projects/astek-landing/node_modules/playwright';
+const { chromium, webkit } = require(PW);
+const BASE = process.env.BASE;
+const MODES = JSON.parse(process.env.MODES);
+const DENSITY_KEY = process.env.DENSITY_KEY || 'grassclothwallcovering_density';
+const results = [];
+const rec=(n,p,d)=>{results.push({name:n,pass:p,detail:d});console.log((p?'PASS ':'FAIL ')+n+'  '+JSON.stringify(d));};
+const step=async(n,fn)=>{try{await fn();}catch(e){rec(n,false,{threw:String(e.message||e).split('\n')[0]});}};
+
+async function fresh(browser){                 // fresh context = empty localStorage, no cross-test bleed
+  const c=await browser.newContext({ignoreHTTPSErrors:true});
+  const p=await c.newPage();
+  await c.route('**/*',r=>{const t=r.request().resourceType();return (t==='image'||t==='font'||t==='media')?r.abort():r.continue();});
+  p.on('console',m=>{if(m.type()==='error')(p._errs=p._errs||[]).push(m.text());});
+  p.on('pageerror',e=>(p._errs=p._errs||[]).push('PAGEERROR '+e.message));
+  return {c,p};
+}
+async function go(p,url){                      // load + settle, retry on non-200
+  for(let i=0;i<3;i++){
+    try{ const r=await p.goto(url,{waitUntil:'domcontentloaded',timeout:25000});
+         if(r&&r.status()===200){await p.waitForSelector('#sortSel',{state:'attached',timeout:20000});return true;} }
+    catch(e){}
+    await p.waitForTimeout(500);
+  }
+  throw new Error('go() could not load '+url);
+}
+const firstSku=p=>p.$eval('.card[data-sku]', e=>e.dataset.sku);
+
+async function run(engine,name){
+  const browser=await engine.launch();
+
+  // 1. each of the 9 sort modes: real navigation + real SERVER ordering + dropdown reflects it
+  for(const [mode,expect] of Object.entries(MODES)){
+    await step(`${name}/sort=${mode}`, async()=>{
+      const {c,p}=await fresh(browser);
+      await go(p,BASE+'/catalog');
+      await Promise.all([ p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),
+                          p.selectOption('#sortSel',mode) ]);
+      await p.waitForSelector('.card[data-sku]',{timeout:20000});
+      const url=p.url(), sku=await firstSku(p), sel=await p.$eval('#sortSel',e=>e.value);
+      rec(`${name}/sort=${mode}`, url.includes('sort='+mode)&&sku===expect&&sel===mode,
+          {url:url.replace(BASE,''),sku,expect,sel});
+      await c.close();
+    });
+  }
+
+  // 2. a search query survives a sort change
+  await step(`${name}/query-retained`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog?q=grasscloth');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','title')]);
+    rec(`${name}/query-retained`, /q=grasscloth/.test(p.url())&&/sort=title/.test(p.url()), {url:p.url().replace(BASE,'')});
+    await c.close();
+  });
+
+  // 3. sorting from page 3 returns to page 1
+  await step(`${name}/page-reset`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog?sort=newest&page=3');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    rec(`${name}/page-reset`, /sort=sku/.test(p.url())&&!/page=[2-9]/.test(p.url()), {url:p.url().replace(BASE,'')});
+    await c.close();
+  });
+
+  // 4. sort is deliberately NOT remembered (Steve, TK-11470): a later bare /catalog visit
+  //    must come back UNSORTED, with the dropdown honestly showing the default.
+  await step(`${name}/sort-not-remembered`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    await p.goto(BASE+'/catalog',{waitUntil:'domcontentloaded',timeout:25000});
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    await p.waitForTimeout(1500);   // give any stray redirect time to fire
+    const sku=await firstSku(p), sel=await p.$eval('#sortSel',e=>e.value);
+    rec(`${name}/sort-not-remembered`,
+        p.url().endsWith('/catalog') && sku===MODES.newest && sel==='newest',
+        {url:p.url().replace(BASE,''), sku, expectDefault:MODES.newest, sel});
+    await c.close();
+  });
+
+  // 4b. the dropdown can never label an ordering the grid isn't in (the old cosmetic lie)
+  await step(`${name}/dropdown-never-lies`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','price-desc')]);
+    await p.goto(BASE+'/catalog',{waitUntil:'domcontentloaded',timeout:25000});
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    await p.waitForTimeout(1200);
+    const sel=await p.$eval('#sortSel',e=>e.value), sku=await firstSku(p);
+    const labelled=MODES[sel];
+    rec(`${name}/dropdown-never-lies`, labelled===sku,
+        {dropdownSays:sel, gridStartsWith:sku, thatSortWouldStartWith:labelled});
+    await c.close();
+  });
+
+  // 5. a bare /catalog visit must not redirect AT ALL (exactly one navigation)
+  await step(`${name}/no-redirect`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    let navs=0; p.on('framenavigated',f=>{if(f===p.mainFrame())navs++;});
+    await p.goto(BASE+'/catalog',{waitUntil:'domcontentloaded',timeout:25000});
+    await p.waitForTimeout(2500);
+    rec(`${name}/no-redirect`, navs===1, {navigations:navs, expect:1});
+    await c.close();
+  });
+
+  // 6. BACK must genuinely undo a sort — this is the regression that decided the design
+  await step(`${name}/back-undoes-sort`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog');
+    const before=await firstSku(p);
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    const sorted=await firstSku(p);
+    await p.goBack({waitUntil:'domcontentloaded',timeout:25000});
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    await p.waitForTimeout(1500);
+    const after=await firstSku(p), sel=await p.$eval('#sortSel',e=>e.value);
+    rec(`${name}/back-undoes-sort`,
+        sorted===MODES.sku && after===before && after===MODES.newest && sel==='newest' && p.url().endsWith('/catalog'),
+        {beforeSort:before, afterSort:sorted, afterBack:after, dropdownAfterBack:sel, url:p.url().replace(BASE,'')});
+    await c.close();
+  });
+
+  // 7. saved density: TWO-SIDED. A one-sided "99 was not applied" would also pass if the
+  //    key were wrong and nothing was ever read at all — a green it did not earn. So first
+  //    prove a VALID value IS applied (read path + key name are real), then prove out-of-range
+  //    values are ignored.
+  await step(`${name}/density-clamped`, async()=>{
+    const probe=async(val)=>{
+      const {c,p}=await fresh(browser);
+      await go(p,BASE+'/catalog');
+      await p.evaluate(([k,v])=>{try{localStorage.setItem(k,v);}catch(e){}},[DENSITY_KEY,val]);
+      await go(p,BASE+'/catalog');
+      await p.waitForTimeout(600);
+      const cols=await p.$eval('#grid',e=>getComputedStyle(e).getPropertyValue('--cols').trim());
+      await c.close(); return cols;
+    };
+    const valid=await probe('7'), high=await probe('99'), low=await probe('1');
+    // baseline (no guard) lets the browser clamp 99 -> "8"; the guard rejects to default "5".
+    rec(`${name}/density-clamped`,
+        valid==='7' && high==='5' && low==='5',
+        {valid_7_applied:valid, invalid_99:high, invalid_1:low, note:'baseline yields 8 here'});
+  });
+
+  // 8. DOM clobbering: query params literally named submit / appendChild
+  await step(`${name}/dom-clobber-survived`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog?submit=x&appendChild=y');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','title')]);
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    const sku=await firstSku(p);
+    rec(`${name}/dom-clobber-survived`, /sort=title/.test(p.url())&&sku===MODES.title, {url:p.url().replace(BASE,''),sku,expect:MODES.title});
+    await c.close();
+  });
+
+  // 9. denied localStorage (private mode / blocked storage) must not break sorting
+  await step(`${name}/denied-storage-still-sorts`, async()=>{
+    const c=await browser.newContext({ignoreHTTPSErrors:true});
+    await c.route('**/*',r=>{const t=r.request().resourceType();return (t==='image'||t==='font'||t==='media')?r.abort():r.continue();});
+    await c.addInitScript(()=>{Object.defineProperty(window,'localStorage',{configurable:true,get(){throw new Error('denied');}});});
+    const p=await c.newPage();
+    await go(p,BASE+'/catalog');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    const sku=await firstSku(p);
+    rec(`${name}/denied-storage-still-sorts`, /sort=sku/.test(p.url())&&sku===MODES.sku, {url:p.url().replace(BASE,''),sku,expect:MODES.sku});
+    await c.close();
+  });
+
+  // 10. zero CSP violations on the catalog page
+  await step(`${name}/no-csp-violations`, async()=>{
+    const {c,p}=await fresh(browser);
+    await go(p,BASE+'/catalog');
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    await p.waitForTimeout(1200);
+    const csp=(p._errs||[]).filter(e=>/Content Security Policy|script-src-attr/i.test(e));
+    rec(`${name}/no-csp-violations`, csp.length===0, {csp:csp.slice(0,2), totalErrs:(p._errs||[]).length});
+    await c.close();
+  });
+
+  // 11. OLD-SAFARI SIMULATION: requestSubmit is Safari 16+. Remove it and the sort must
+  //     still work via the submit() fallback — otherwise the patch silently reintroduces
+  //     the very dead-dropdown bug it fixes, on exactly the browsers we cannot test natively.
+  await step(`${name}/legacy-no-requestSubmit`, async()=>{
+    const c=await browser.newContext({ignoreHTTPSErrors:true});
+    await c.route('**/*',r=>{const t=r.request().resourceType();return (t==='image'||t==='font'||t==='media')?r.abort():r.continue();});
+    await c.addInitScript(()=>{ try{ delete HTMLFormElement.prototype.requestSubmit; }catch(e){} });
+    const p=await c.newPage();
+    const perr=[]; p.on('pageerror',e=>perr.push(e.message));
+    await go(p,BASE+'/catalog');
+    const gone=await p.evaluate(()=>typeof HTMLFormElement.prototype.requestSubmit);
+    await Promise.all([p.waitForNavigation({waitUntil:'domcontentloaded',timeout:25000}),p.selectOption('#sortSel','sku')]);
+    await p.waitForSelector('.card[data-sku]',{timeout:20000});
+    const sku=await firstSku(p);
+    rec(`${name}/legacy-no-requestSubmit`,
+        gone==='undefined' && /sort=sku/.test(p.url()) && sku===MODES.sku && perr.length===0,
+        {requestSubmitTypeof:gone, url:p.url().replace(BASE,''), sku, expect:MODES.sku, pageerrors:perr.slice(0,2)});
+    await c.close();
+  });
+
+  await browser.close();
+}
+(async()=>{
+  await run(chromium,'chromium');
+  await run(webkit,'webkit');
+  const fail=results.filter(r=>!r.pass);
+  console.log('\n==== '+(results.length-fail.length)+' PASS / '+fail.length+' FAIL of '+results.length+' ====');
+  require('fs').writeFileSync(process.env.OUT||'/tmp/tk11470-proof/result.json',
+    JSON.stringify({base:BASE,pass:results.length-fail.length,fail:fail.length,results},null,2));
+  process.exit(fail.length?1:0);
+})();
diff --git a/scripts/tk11470-sort-proof/tlsfront.cjs b/scripts/tk11470-sort-proof/tlsfront.cjs
new file mode 100644
index 0000000..871cb14
--- /dev/null
+++ b/scripts/tk11470-sort-proof/tlsfront.cjs
@@ -0,0 +1,8 @@
+const https=require('https'), http=require('http'), fs=require('fs');
+const UP=process.env.UP||'127.0.0.1:18471', PORT=+(process.env.TLS_PORT||18472);
+https.createServer({key:fs.readFileSync('/tmp/tk11470-proof/key.pem'),cert:fs.readFileSync('/tmp/tk11470-proof/cert.pem')},(req,res)=>{
+  const [h,p]=UP.split(':');
+  const r=http.request({host:h,port:+p,path:req.url,method:req.method,headers:{...req.headers,host:`127.0.0.1:${PORT}`}},u=>{res.writeHead(u.statusCode,u.headers);u.pipe(res);});
+  r.on('error',e=>{res.writeHead(502);res.end('proxy '+e.message);});
+  req.pipe(r);
+}).listen(PORT,()=>console.log('TLS front on https://127.0.0.1:'+PORT+' -> '+UP));

← d0f8c70 catalog sort: stop remembering the sort, so Back works and t  ·  back to Dw Domain Fleet  ·  TK-11535: encode sort in pager() href — close the unescaped- 35ab401 →