← 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
A scripts/tk11470-sort-proof/README.mdA scripts/tk11470-sort-proof/modes.jsonA scripts/tk11470-sort-proof/proof2.cjsA scripts/tk11470-sort-proof/tlsfront.cjs
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 →