[object Object]

← back to Pattern Vault

fernwick: fail-loud upload_sf (no false-green id:null), filechooser+consent upload path, daily_run false-green guard, read-only SF diagnostic

6393b5429d943bd498b483bf9e0916fc85fa469c · 2026-07-09 16:21:29 -0700 · Steve

Files touched

Diff

commit 6393b5429d943bd498b483bf9e0916fc85fa469c
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 16:21:29 2026 -0700

    fernwick: fail-loud upload_sf (no false-green id:null), filechooser+consent upload path, daily_run false-green guard, read-only SF diagnostic
---
 whimsical-compare/daily/scripts/daily_run.sh       | 20 ++++++-
 .../daily/scripts/diagnose_sf_upload.js            | 54 +++++++++++++++++
 whimsical-compare/daily/scripts/upload_sf.js       | 69 ++++++++++++++++++----
 3 files changed, 127 insertions(+), 16 deletions(-)

diff --git a/whimsical-compare/daily/scripts/daily_run.sh b/whimsical-compare/daily/scripts/daily_run.sh
index 5041d11..d5fa783 100755
--- a/whimsical-compare/daily/scripts/daily_run.sh
+++ b/whimsical-compare/daily/scripts/daily_run.sh
@@ -74,8 +74,22 @@ node "$HERE/report.js" "$MANIFEST" "$UPRESULT" || echo "report send failed"
 COUNT=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['count'])")
 COST=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['est_cost_usd'])")
 ARMED_STATE=$([ -f "$DAILY/ARMED" ] && echo true || echo false)
+# real success signal = number of designs that got a REAL Spoonflower id (upload.json .uploaded[])
+UPLOADED=$(python3 -c "import json;print(len(json.load(open('$UPRESULT')).get('uploaded',[])))" 2>/dev/null || echo 0)
+FAILED=$(python3 -c "import json;print(len(json.load(open('$UPRESULT')).get('failed',[])))" 2>/dev/null || echo 0)
+
+# false-green guard: an ARMED run that landed ZERO real uploads is a FAILURE — do NOT mark the
+# day done (so the next launchd fire retries) and record it loudly. Dry-run success = generated.
+if [ "$ARMED_STATE" = "true" ] && [ "$UPLOADED" -eq 0 ]; then
+  JOB_STATUS="upload_failed"
+  echo "!!! ARMED but 0 real uploads ($FAILED failed) — NOT marking done; will retry next fire"
+  python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=False;d['upload_failed']=True;json.dump(d,open(p,'w'))"
+else
+  JOB_STATUS="done"
+  python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=True;d.pop('upload_failed',None);json.dump(d,open(p,'w'))"
+fi
+
 psql "postgresql:///dw_unified?host=/tmp" -c \
-  "INSERT INTO spoonflower_jobs (kind,status,dry_run,params,result,finished_at) VALUES ('fernwick_daily','done',$([ -f "$DAILY/ARMED" ] && echo false || echo true),'{\"date\":\"$TODAY\",\"n\":$COUNT}'::jsonb,'{\"count\":$COUNT,\"cost\":$COST,\"armed\":$ARMED_STATE}'::jsonb,now());" >/dev/null 2>&1 || true
+  "INSERT INTO spoonflower_jobs (kind,status,dry_run,params,result,finished_at) VALUES ('fernwick_daily','$JOB_STATUS',$([ -f "$DAILY/ARMED" ] && echo false || echo true),'{\"date\":\"$TODAY\",\"n\":$COUNT}'::jsonb,'{\"count\":$COUNT,\"uploaded\":$UPLOADED,\"failed\":$FAILED,\"cost\":$COST,\"armed\":$ARMED_STATE}'::jsonb,now());" >/dev/null 2>&1 || true
 
-python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=True;json.dump(d,open(p,'w'))"
-echo "=== DONE: $COUNT designs, est \$$COST, armed=$ARMED_STATE ==="
+echo "=== $JOB_STATUS: $COUNT generated, $UPLOADED uploaded, $FAILED failed, est \$$COST, armed=$ARMED_STATE ==="
diff --git a/whimsical-compare/daily/scripts/diagnose_sf_upload.js b/whimsical-compare/daily/scripts/diagnose_sf_upload.js
new file mode 100644
index 0000000..ba2eafa
--- /dev/null
+++ b/whimsical-compare/daily/scripts/diagnose_sf_upload.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+/*
+ * READ-ONLY Spoonflower upload-page diagnostic — NO design is created, nothing is published.
+ * Loads the real session, opens the upload page, dumps the actual inputs/buttons, optionally
+ * stages a file into the dropzone (which does NOT submit), and re-dumps. Reveals the real
+ * Save/Publish control + completion flow so upload_sf.js can be calibrated. Prints JSON.
+ *
+ * Usage: node diagnose_sf_upload.js [optional-png-to-stage]
+ */
+const { chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
+const path = require('path'), os = require('os');
+const STATE = process.env.SF_STATE || path.join(os.homedir(), '.config/pattern-vault/sf-state.json');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/126 Safari/537.36';
+const stageFile = process.argv[2] || null;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function dump(pg, label) {
+  const inputs = await pg.$$eval('input, textarea', els => els.map(e => ({
+    tag: e.tagName.toLowerCase(), type: e.type || '', name: e.name || '', id: e.id || '',
+    placeholder: e.placeholder || '', aria: e.getAttribute('aria-label') || '',
+  }))).catch(() => []);
+  const buttons = await pg.$$eval('button, [role=button], a[href], input[type=submit]', els =>
+    [...new Set(els.map(e => (e.innerText || e.value || '').trim()).filter(Boolean))].slice(0, 40)).catch(() => []);
+  const fileInputs = await pg.$$eval('input[type=file]', els => els.length).catch(() => 0);
+  return { label, url: pg.url(), fileInputs, inputs: inputs.slice(0, 20), buttons };
+}
+
+(async () => {
+  const out = { state: STATE, steps: [] };
+  const b = await chromium.launch({ headless: true });
+  const ctx = await b.newContext({ storageState: STATE, userAgent: UA });
+  const pg = await ctx.newPage();
+  try {
+    await pg.goto('https://www.spoonflower.com/en/my_account', { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+    await sleep(2500);
+    out.sessionValid = !pg.url().includes('/login');
+    if (!out.sessionValid) { out.note = 'SESSION EXPIRED — re-capture with capture_sf_session.js'; console.log(JSON.stringify(out, null, 2)); await b.close(); return; }
+
+    await pg.goto('https://www.spoonflower.com/artists/designs/new', { waitUntil: 'domcontentloaded', timeout: 40000 }).catch(() => {});
+    await sleep(3000);
+    out.steps.push(await dump(pg, 'upload-page-loaded'));
+
+    if (stageFile) {
+      const fi = await pg.$('input[type=file]');
+      if (fi) {
+        await fi.setInputFiles(stageFile).catch(e => out.stageError = String(e).slice(0, 120));
+        await sleep(8000); // let the upload/preview process — still NOT submitted
+        out.steps.push(await dump(pg, 'after-file-staged'));
+      } else { out.stageError = 'no file input found to stage'; }
+    }
+  } catch (e) { out.error = String(e).slice(0, 200); }
+  await b.close();
+  console.log(JSON.stringify(out, null, 2));
+})();
diff --git a/whimsical-compare/daily/scripts/upload_sf.js b/whimsical-compare/daily/scripts/upload_sf.js
index bef0528..90b9372 100755
--- a/whimsical-compare/daily/scripts/upload_sf.js
+++ b/whimsical-compare/daily/scripts/upload_sf.js
@@ -66,7 +66,7 @@ async function messyType(el, text) {
 
   if (!fs.existsSync(STATE)) { result.sessionExpired = true; result.error = 'no session file'; console.log(JSON.stringify(result)); return; }
 
-  const b = await chromium.launch({ headless: !ARMED, args: ['--start-maximized'] });
+  const b = await chromium.launch({ headless: (process.env.SF_HEADLESS === '1') ? true : !ARMED, args: ['--start-maximized'] });
   const ctx = await b.newContext({ viewport: ARMED ? null : { width: 1400, height: 900 }, storageState: STATE, userAgent: UA });
   const pg = await ctx.newPage();
 
@@ -113,8 +113,29 @@ async function messyType(el, text) {
       if (!ARMED) { result.uploaded.push({ title: d.title, dryRun: true, id: null, url: null }); await sleep(rnd(600, 1200)); continue; }
 
       // ---- ARMED: real upload ----
-      await fileInput.setInputFiles(d.file);
-      await sleep(rnd(4000, 9000)); // upload + processing
+      // Dismiss any consent/privacy overlay first — it intercepts the uploader clicks.
+      // DIAGNOSED 2026-07-09: a "Your Privacy Choices → Agree & Continue" modal ate the click.
+      for (const sel of ['button:has-text("Agree & Continue")', 'button:has-text("Accept All")',
+                         'button:has-text("Accept")', 'button:has-text("I Agree")', '#onetrust-accept-btn-handler']) {
+        const c = await pg.$(sel);
+        if (c) { await c.click().catch(() => {}); await sleep(rnd(700, 1500)); break; }
+      }
+
+      // Drive the REAL uploader via the "Choose Files" button + native filechooser.
+      // DIAGNOSED 2026-07-09: setInputFiles() on the hidden input is a NO-OP against
+      // Spoonflower's React dropzone (file never registers → upload never starts → id:null).
+      const [chooser] = await Promise.all([
+        pg.waitForEvent('filechooser', { timeout: 12000 }).catch(() => null),
+        pg.click('button:has-text("Choose Files"), text="Choose Files"').catch(() => null),
+      ]);
+      if (chooser) {
+        await chooser.setFiles(d.file);
+      } else {
+        // fallback: stage the hidden input AND dispatch change so a plain-input flow still fires
+        await fileInput.setInputFiles(d.file);
+        await fileInput.dispatchEvent('change').catch(() => {});
+      }
+      await sleep(rnd(5000, 10000)); // upload + processing → design-edit page should appear
 
       // title field (varies across UI versions)
       const titleEl = await pg.$('input[name*=title i], input[placeholder*=title i], input[aria-label*=title i], #design-name');
@@ -124,16 +145,38 @@ async function messyType(el, text) {
       await pg.mouse.move(rnd(300, 1000), rnd(200, 700)); // idle jitter
       await sleep(rnd(800, 2000));
 
-      // save / done
-      const save = await pg.$('button:has-text("Save"), button:has-text("Done"), button:has-text("Finish"), button[type=submit]');
-      if (save) { await save.click().catch(() => {}); }
-      await sleep(rnd(4000, 8000));
-
-      // capture the design id/url off the resulting page
-      const url = pg.url();
-      const idm = url.match(/designs?\/(\d+)/);
-      result.uploaded.push({ title: d.title, id: idm ? idm[1] : null,
-        url: idm ? `https://www.spoonflower.com/artists/designs/${idm[1]}` : url });
+      // save / done / publish — try known labels; remember if we actually clicked one
+      let saveClicked = false;
+      for (const sel of ['button:has-text("Save")', 'button:has-text("Done")', 'button:has-text("Finish")',
+                         'button:has-text("Publish")', 'button:has-text("Upload")', 'button[type=submit]']) {
+        const el = await pg.$(sel);
+        if (el) { await el.click().catch(() => {}); saveClicked = true; break; }
+      }
+
+      // Poll up to ~20s for a REAL design id (URL nav to /designs/<id>, or a link in the DOM).
+      // A completed Spoonflower upload lands on a design page; the blank /artists/designs/new form does not.
+      let designId = null;
+      for (let t = 0; t < 20 && !designId; t++) {
+        const m = pg.url().match(/designs?\/(\d+)/);
+        if (m) { designId = m[1]; break; }
+        const href = await pg.$eval('a[href*="/designs/"]', a => a.getAttribute('href')).catch(() => null);
+        const hm = href && href.match(/designs?\/(\d+)/);
+        if (hm) { designId = hm[1]; break; }
+        await sleep(1000);
+      }
+
+      if (designId) {
+        result.uploaded.push({ title: d.title, id: designId, private: true,
+          url: `https://www.spoonflower.com/artists/designs/${designId}` });
+      } else {
+        // FAIL-LOUD: no design id => NOT a success. Record the exact stuck state + visible buttons
+        // so the next run tells us the real Save control (self-diagnosing calibration).
+        const buttonsSeen = await pg.$$eval('button, [role=button], input[type=submit]',
+          els => [...new Set(els.map(e => (e.innerText || e.value || '').trim()).filter(Boolean))].slice(0, 30)).catch(() => []);
+        result.failed.push({ title: d.title,
+          reason: `upload did not complete — no design id after Save (saveClicked=${saveClicked}, stuck at ${pg.url()})`,
+          buttonsSeen });
+      }
       await sleep(rnd(3000, 9000)); // human gap between uploads
     } catch (e) {
       result.failed.push({ title: d.title, reason: String(e).slice(0, 160) });

← 829fadb auto-save: 2026-07-08T10:35:15 (1 files) — whimsical-compare  ·  back to Pattern Vault  ·  wpb-uploaders: disarmed per-platform framework (etsy/CM/redb 5542bd8 →