diff --git a/docs/release-manifest.json b/docs/release-manifest.json index bfa91df..e7bdbe7 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v92-20260918-proposal-quality", + "pwaCache": "v93-20260918-sync-demo-images", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, @@ -331,7 +331,7 @@ "clientOfferTemplatePerOrder": true, "menuStyledSvgIcon": true, "releaseIntegrityChecks": true, - "cloudRpcTimeoutMs": 12000, + "cloudRpcTimeoutMs": 45000, "cloudConflictMaxRetries": 4, "errorLogDedupMinutes": 5, "networkFailureBackoff": true, @@ -400,9 +400,17 @@ "stockProducts": 43, "suppliers": 5, "autoNewTrial": true, - "preservesSolnce": true + "preservesSolnce": true, + "imageFormat": "webp", + "imageMaxSidePx": 1024, + "imageBytes": 1343650, + "legacyImageLinksPreserved": true }, "proposalLayout": "core/proposal-layout.js", "proposalLocalCyrillicFonts": true, - "proposalRasterDpi": 290 + "proposalRasterDpi": 290, + "cloudReadTimeoutMs": 12000, + "cloudFallbackTimeoutMs": 15000, + "cloudWriteTimeoutMs": 35000, + "cloudSafeNetworkRetries": 3 } diff --git a/docs/releases/2026-09-18-SYNC-DEMO-IMAGES.md b/docs/releases/2026-09-18-SYNC-DEMO-IMAGES.md new file mode 100644 index 0000000..fbe33a0 --- /dev/null +++ b/docs/releases/2026-09-18-SYNC-DEMO-IMAGES.md @@ -0,0 +1,30 @@ +# Login, synchronization and trial image loading + +Release cache: `v93-20260918-sync-demo-images`. + +The previous transport aborted every request, including saves, after seven +seconds. A slow response could therefore surface as `AbortError: signal is +aborted without reason`. Reads had only four seconds on the fallback route. + +- Reads and password login now allow 12 seconds through the proxy and 15 seconds + through the same Supabase project's direct route. A successful fallback is + preferred for 60 seconds, avoiding another wait on the failing route. +- Writes have a separate 35-second limit, longer than the proxy's 30-second + upstream limit. A write is never replayed by the transport. The outer cloud + operation deadline is 45 seconds so it cannot overtake a normal save. +- A transient sync failure retries after 2, 5 and 15 seconds, at most three + times. Each retry fetches the server revision and merges again, including when + a save committed but its response was lost. After exhaustion the UI shows a + readable connection error and keeps local changes. +- A late sync/pull response is ignored after account/workspace changes or local + sign-out. Caller cancellation is preserved and does not start a fallback. +- Ten trial photos now use 1024px WebP assets: **1,343,650 bytes instead of + 22,930,358 bytes** (17.1 times smaller). The original images remain available + for rollback. Existing catalog/offer PNG references resolve to the optimized + files; customer photos and database records are not rewritten. +- The service worker caches same-origin app assets only, excluding API routes. + +Validation covers delayed reads/saves, route fallback, cancellation, an uncertain +commit without a duplicate save, stale account responses, the actual SDK login, +all ten image dimensions and the 1.5 MB combined budget. Full application checks +also cover trial TTK/stock/procurement and all 21 proposal designs. diff --git a/ops/demo/optimize-demo-images.py b/ops/demo/optimize-demo-images.py new file mode 100644 index 0000000..fe4f162 --- /dev/null +++ b/ops/demo/optimize-demo-images.py @@ -0,0 +1,15 @@ +"""Build lightweight display assets while retaining original demo PNGs for rollback.""" +from pathlib import Path +from PIL import Image + +root = Path(__file__).resolve().parents[2] / "public" / "demo" / "images" +original_bytes = optimized_bytes = 0 +for source in sorted(root.glob("*.png")): + with Image.open(source) as image: + image = image.convert("RGB") + image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) + target = source.with_suffix(".webp") + image.save(target, "WEBP", quality=83, method=6) + original_bytes += source.stat().st_size + optimized_bytes += target.stat().st_size +print(f"Demo images: {original_bytes:,} -> {optimized_bytes:,} bytes") diff --git a/public/.htaccess b/public/.htaccess index 4d7c95d..960e128 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -17,3 +17,8 @@ ExpiresByType text/html "access plus 0 seconds" ExpiresByType application/javascript "access plus 0 seconds" + + RewriteEngine On + # Compatibility for trial catalogs and saved documents with the original URLs. + RewriteRule ^demo/images/(berry-dessert|bruschetta-tomato|caprese|cheese-fruit|chicken-sandwich|meat-assortment|mushroom-tartlet|salmon-cream|turkey-wrap|vegetables-hummus)\.png$ demo/images/$1.webp [L] + diff --git a/public/app-runtime.js b/public/app-runtime.js index 067221f..9d5e78a 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -67,7 +67,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; } function loadImage(src){ return new Promise(resolve=>{ - const raw=imageSource(src),img=new Image();let done=false; + const raw=window.SunSafe.imageAssetSrc(imageSource(src)),img=new Image();let done=false; const finish=value=>{if(done)return;done=true;clearTimeout(timer);resolve(value)}; try{const url=new URL(raw,document.baseURI||location.href);if(/^https?:$/i.test(url.protocol)&&url.origin!==location.origin)img.crossOrigin='anonymous'}catch(_){} img.onload=()=>finish(img); @@ -445,7 +445,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); const all=boxes.filter(item=>Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});renderOrderLines();updateOrderSummary();return;} - const cards=items.map(item=>``).join(''); + const cards=items.map(item=>``).join(''); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`; $('tiles').innerHTML=`${cards}${empty}`; @@ -462,7 +462,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function renderManagerV5(){ ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)} const items=boxes.filter(i=>Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; - $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'

Ничего не найдено.

'; + $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'

Ничего не найдено.

'; } window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; window.editBox=id=>{ @@ -1088,7 +1088,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const isSafeRasterDataUrl=src=>/^data:image\/(?!svg\+xml)[a-z0-9.+-]+;/i.test(String(src||'')); async function asDataUrlOne(src){ if(!src)return''; - const raw=String(src).trim(); + const raw=window.SunSafe.imageAssetSrc(src); if(isSafeRasterDataUrl(raw))return raw; if(/^data:/i.test(raw))return''; try{ @@ -2116,7 +2116,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const DEFAULT_SUPABASE_URL = 'https://usfjwhztqoopzzfmfbis.supabase.co'; const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const SUPABASE_API_PROXY = 'https://api.caterium.ru'; - const PROXY_FETCH_TIMEOUT_MS = 7000; + const PROXY_FETCH_TIMEOUT_MS = 12000; const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); let config = loadConfig(); @@ -2132,9 +2132,10 @@ window.SUN_LEGACY_CATALOG_V175=[]; let membershipLoad = null; let realtimeChannel = null; let syncTimer = null; + let networkRetryTimer = null; let isSyncing = false; let dirty = false; - const CLOUD_RPC_TIMEOUT_MS=12000; + const CLOUD_RPC_TIMEOUT_MS=45000; const CLOUD_CONFLICT_MAX_RETRIES=4; function errText(e,fallback='Неизвестная ошибка'){ if(e==null)return fallback; @@ -2456,7 +2457,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if(signOutInProgress)return; signOutInProgress=true; setSignOutUiBusy(true); - clearTimeout(syncTimer); + clearTimeout(syncTimer);clearTimeout(networkRetryTimer); setStatus('syncing','Выходим из аккаунта…'); try{ // Preserve the company working copy first. Do not block logout on a cloud @@ -2757,7 +2758,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; return out; } - async function applyPayload(payload, replace=false) { + async function applyPayload(payload, replace=false, current=()=>true) { + if(!current())throw new DOMException('Компания сменилась.','AbortError'); if (!payload?.storage || typeof payload.storage !== 'object') throw new Error('Некорректный формат облачной базы.'); suppressStorageTracking = true; try { @@ -2769,6 +2771,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; if (!shouldSyncKey(key) || !entry) continue; let decoded = await decodeMedia(entry.v); if (key === 'sunBoxes' && entry.t === 'j') decoded = await compactSunBoxesForStorage(decoded,{maxSide:900,maxChars:130000,quality:.78}); + if(!current())throw new DOMException('Компания сменилась.','AbortError'); let raw = entry.t === 'j' ? JSON.stringify(decoded) : String(decoded ?? ''); try { localStorage.setItem(key, raw); @@ -2849,16 +2852,21 @@ window.SUN_LEGACY_CATALOG_V175=[]; } async function pullRemote({replace=false,quiet=false}={}) { - if (!client || !workspace?.id) return; + if (signOutInProgress || !client || !session || !workspace?.id) return; + const startedUser=session.user.id,startedWorkspace=workspace.id; + const current=()=>!signOutInProgress&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace; setStatus('syncing','Загружаю облачные данные…'); try { const row = await fetchRemoteRow(); + if(!current())return; if (!row || payloadEmpty(row.payload)) { setStatus('ready','В облаке пока нет данных.'); return; } if (!quiet) await backupBeforeCloud('Перед загрузкой данных из Supabase'); - await applyPayload(row.payload, replace); + await applyPayload(row.payload, replace, current); + if(!current())return; await setBaseline(row); + if(!current())return; config.migrated[workspace.id] = true; config.lastSync = row.updated_at || new Date().toISOString(); saveConfig(); dirty=false; @@ -2866,26 +2874,32 @@ window.SUN_LEGACY_CATALOG_V175=[]; if (!quiet) toast('Данные загружены из облака.','success'); try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(row.payload),kind:'pull'}}));}catch(_){} renderCloudUI(); - } catch (error) { handleError(error,'Не удалось загрузить облачные данные.'); } + } catch (error) { if(current())handleError(error,'Не удалось загрузить облачные данные.'); } } - async function syncNow({quiet=false,retryCount=0}={}) { - if (isSyncing || !client || !session || !workspace?.id || !workspaceMigrated()) return; + async function syncNow({quiet=false,retryCount=0,networkRetry=0}={}) { + if (signOutInProgress || isSyncing || !client || !session || !workspace?.id || !workspaceMigrated()) return; if (!navigator.onLine) { setStatus('offline','Офлайн — изменения ждут подключения.'); return; } + clearTimeout(networkRetryTimer); + const startedUser=session.user.id,startedWorkspace=workspace.id; + const current=()=>!signOutInProgress&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace; isSyncing = true; setStatus('syncing','Синхронизация…'); try { const [baseline, remote, localPayload] = await Promise.all([getBaseline(), fetchRemoteRow(), collectLocalPayload()]); + if(!current())return; if (!remote || payloadEmpty(remote.payload)) { if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); const row = await upsertPayload(localPayload, remote?.revision ?? null); - await setBaseline(row); dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); + if(!current())return; + await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; } if (!baseline) { - await applyPayload(remote.payload, true); - await setBaseline(remote); dirty=false; config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); + await applyPayload(remote.payload, true, current); + if(!current())return; + await setBaseline(remote); if(!current())return; dirty=false; config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Подключена существующая облачная база.'); if(!quiet)toast('Загружена актуальная облачная база.','success'); return; } @@ -2896,17 +2910,19 @@ window.SUN_LEGACY_CATALOG_V175=[]; if (changedVsRemote) { if (!canWrite()) { - await applyPayload(remote.payload, true); + await applyPayload(remote.payload, true, current); finalRow = remote; } else { finalRow = await upsertPayload(merged.payload, remote.revision); - if (changedVsLocal) await applyPayload(merged.payload, true); + if (changedVsLocal) await applyPayload(merged.payload, true, current); } } else if (changedVsLocal) { - await applyPayload(remote.payload, true); + await applyPayload(remote.payload, true, current); } + if(!current())return; await setBaseline(finalRow); + if(!current())return; dirty=false; config.lastSync=finalRow.updated_at||new Date().toISOString();saveConfig(); setStatus('ready', merged.conflicts.length ? `Синхронизировано, объединено конфликтов: ${merged.conflicts.length}.` : 'Синхронизировано.'); @@ -2916,12 +2932,19 @@ window.SUN_LEGACY_CATALOG_V175=[]; window.SunStabilityV17?.markSynced?.(finalRow?.revision); renderCloudUI(); } catch (error) { + if(!current())return; const msg=String(error?.message||''); if(msg.includes('SUN_CONFLICT')){ const nextRetry=Number(retryCount||0)+1; try{window.SunStabilityV17?.recordConflict?.(msg);}catch(_){} if(nextRetry>CLOUD_CONFLICT_MAX_RETRIES){handleError(error,'Не удалось синхронизировать после нескольких безопасных повторов.');} else{const delay=Math.min(5000,400*Math.pow(2,nextRetry-1));setStatus('pending','Облачная версия изменилась. Повтор '+nextRetry+'/'+CLOUD_CONFLICT_MAX_RETRIES+' через '+(Math.round(delay/100)/10)+' сек.…');setTimeout(()=>syncNow({quiet:true,retryCount:nextRetry}),delay);} + }else if(window.CateriumCloudTransport.isTransient(error)&&networkRetry<3){ + const delay=[2000,5000,15000][networkRetry]; + setStatus('pending','Соединение прервалось. Изменения сохранены на устройстве; повторяю синхронизацию…'); + // Re-read the remote revision and merge before any write. A timed-out + // save may already have committed; never blindly resend its request. + networkRetryTimer=setTimeout(()=>{if(current())syncNow({quiet:true,networkRetry:networkRetry+1})},delay); }else handleError(error,'Ошибка синхронизации.'); } finally { isSyncing=false; } @@ -3189,7 +3212,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; return false; }catch(error){ if(!current())return false; - membershipError=error?.message||'Не удалось загрузить рабочую базу.'; + membershipError=window.CateriumCloudTransport.errorMessage(error); setStatus('error',membershipError); return false; } @@ -3275,7 +3298,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; } function handleError(error, message) { - console.error('[SunCloudV2]',error);const detail=String(error?.message||error||'Неизвестная ошибка');setStatus('error',`${message} ${detail}`);toast(`${message} ${detail}`,'error',7500);renderCloudUI(); + if(signOutInProgress)return; + console.error('[SunCloudV2]',error);const detail=window.CateriumCloudTransport.errorMessage(error);setStatus('error',`${message} ${detail}`);toast(`${message} ${detail}`,'error',7500);renderCloudUI(); } function observeSettings() { diff --git a/public/core/auth-security-v1774.js b/public/core/auth-security-v1774.js index ed052fe..f771c39 100644 --- a/public/core/auth-security-v1774.js +++ b/public/core/auth-security-v1774.js @@ -46,7 +46,7 @@ const persisted=await c.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.'); setError(gate,'Вход выполнен. Открываю Caterium…'); setTimeout(()=>location.reload(),120); - }catch(error){setError(gate,String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;} + }catch(error){setError(gate,window.CateriumCloudTransport?.errorMessage(error)||String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;} } async function publicSignup(gate){ diff --git a/public/core/cloud-transport.js b/public/core/cloud-transport.js index 9e4161c..ea43201 100644 --- a/public/core/cloud-transport.js +++ b/public/core/cloud-transport.js @@ -1,8 +1,11 @@ (()=>{ 'use strict'; const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']); - function create({upstream,proxy,timeout=7000,fallbackTimeout=4000}){ + const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||'')); + const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка'); + function create({upstream,proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){ const origin=new URL(upstream).origin; + let directUntil=0; return async function(input,init={}){ const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin; const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length))); @@ -12,8 +15,9 @@ async function attempt(target,limit){ const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason); if(original.signal.aborted)abort();else original.signal.addEventListener('abort',abort,{once:true}); - const timer=setTimeout(()=>controller.abort(),limit); + const timer=setTimeout(()=>controller.abort(new DOMException('Сервер не ответил вовремя. Проверьте соединение и повторите загрузку.','TimeoutError')),limit); try{ + if(controller.signal.aborted)throw controller.signal.reason; const response=await fetch(new Request(target,original.clone()),{signal:controller.signal}); if(expectJson&&response.ok&&response.status!==204){ const text=await response.clone().text(); @@ -23,13 +27,18 @@ return response; }finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)} } + const directFirst=isBackend&&Date.now(){ 'use strict'; const VERSION='17.7.3'; - const RELEASE='20260918-proposal-quality'; + const RELEASE='20260918-sync-demo-images'; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; function installAuthBoot(){ diff --git a/public/core/sun-safe.js b/public/core/sun-safe.js index 7ead66e..08f6c39 100644 --- a/public/core/sun-safe.js +++ b/public/core/sun-safe.js @@ -5,8 +5,17 @@ })[ch]); const escapeAttr=escapeHTML; const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,''); + // Existing trial databases and saved offers still reference the PNG originals. + // Resolve only our ten bundled demo assets; never rewrite customer photos. + const demoNames='berry-dessert|bruschetta-tomato|caprese|cheese-fruit|chicken-sandwich|meat-assortment|mushroom-tartlet|salmon-cream|turkey-wrap|vegetables-hummus'; + const demoPath=new RegExp('^/(demo/images/(?:'+demoNames+'))\\.png$'); + const imageAssetSrc=value=>{ + const src=String(value??'').trim(); + try{const url=new URL(src,document.baseURI);const match=url.pathname.match(demoPath);if(url.origin===location.origin&&match)return match[1]+'.webp'+url.search+url.hash;}catch(_){} + return src; + }; const safeImageSrc=value=>{ - const s=String(value??'').trim(); + const s=imageAssetSrc(value); if(!s)return ''; if(/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(s))return s; if(/^(?:\.\/|\.\.\/|\/)?[a-z0-9_./-]+\.(?:png|jpe?g|webp|gif)(?:[?#][^\s]*)?$/i.test(s))return s; @@ -19,7 +28,7 @@ if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node); return node; }; - window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,safeImageSrc,setText,insertBefore}); + window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,safeImageSrc,imageAssetSrc,setText,insertBefore}); // Small bootstrap for account/profile UI. Keeping it here makes the account // center available on every Caterium screen without touching the legacy monolith. @@ -30,4 +39,4 @@ script.async=true; document.head.appendChild(script); } -})(); \ No newline at end of file +})(); diff --git a/public/demo/images/berry-dessert.webp b/public/demo/images/berry-dessert.webp new file mode 100644 index 0000000..e427943 Binary files /dev/null and b/public/demo/images/berry-dessert.webp differ diff --git a/public/demo/images/bruschetta-tomato.webp b/public/demo/images/bruschetta-tomato.webp new file mode 100644 index 0000000..0656232 Binary files /dev/null and b/public/demo/images/bruschetta-tomato.webp differ diff --git a/public/demo/images/caprese.webp b/public/demo/images/caprese.webp new file mode 100644 index 0000000..6d7ee29 Binary files /dev/null and b/public/demo/images/caprese.webp differ diff --git a/public/demo/images/cheese-fruit.webp b/public/demo/images/cheese-fruit.webp new file mode 100644 index 0000000..eb2478c Binary files /dev/null and b/public/demo/images/cheese-fruit.webp differ diff --git a/public/demo/images/chicken-sandwich.webp b/public/demo/images/chicken-sandwich.webp new file mode 100644 index 0000000..fa8f106 Binary files /dev/null and b/public/demo/images/chicken-sandwich.webp differ diff --git a/public/demo/images/meat-assortment.webp b/public/demo/images/meat-assortment.webp new file mode 100644 index 0000000..2730c57 Binary files /dev/null and b/public/demo/images/meat-assortment.webp differ diff --git a/public/demo/images/mushroom-tartlet.webp b/public/demo/images/mushroom-tartlet.webp new file mode 100644 index 0000000..40bd893 Binary files /dev/null and b/public/demo/images/mushroom-tartlet.webp differ diff --git a/public/demo/images/salmon-cream.webp b/public/demo/images/salmon-cream.webp new file mode 100644 index 0000000..0c45b90 Binary files /dev/null and b/public/demo/images/salmon-cream.webp differ diff --git a/public/demo/images/turkey-wrap.webp b/public/demo/images/turkey-wrap.webp new file mode 100644 index 0000000..d4834a9 Binary files /dev/null and b/public/demo/images/turkey-wrap.webp differ diff --git a/public/demo/images/vegetables-hummus.webp b/public/demo/images/vegetables-hummus.webp new file mode 100644 index 0000000..5bae7b5 Binary files /dev/null and b/public/demo/images/vegetables-hummus.webp differ diff --git a/public/index.html b/public/index.html index fd5a099..ebe5afe 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index 46f8a7c..01c18fe 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ -const CACHE='sun-catering-pwa-v92-20260918-proposal-quality'; -const VERSION='20260918-proposal-quality'; +const CACHE='sun-catering-pwa-v93-20260918-sync-demo-images'; +const VERSION='20260918-sync-demo-images'; const CORE=[ './','./index.html',`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, @@ -30,7 +30,7 @@ function forceFresh(req){ } self.addEventListener('fetch',event=>{ const req=event.request;if(req.method!=='GET')return; - const url=new URL(req.url);if(url.pathname.startsWith('/api/'))return; + const url=new URL(req.url);if(url.origin!==self.location.origin||url.pathname.startsWith('/api/'))return; if(req.mode==='navigate'){ event.respondWith( fetch(req,{cache:'no-store'}) diff --git a/tests/login-recovery.spec.mjs b/tests/login-recovery.spec.mjs index 5e12b65..42ca29c 100644 --- a/tests/login-recovery.spec.mjs +++ b/tests/login-recovery.spec.mjs @@ -1,13 +1,74 @@ import fs from 'node:fs'; import {test,expect} from '@playwright/test'; +test('slow connections use a healthy route, allow longer saves and preserve caller cancellation',async({page})=>{ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); + await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); + const result=await page.evaluate(async()=>{ + const upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[]; + const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}}); + window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())}; + const send=CateriumCloudTransport.create({upstream,proxy,timeout:10,fallbackTimeout:100,writeTimeout:100}); + await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'}); + await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'}); + const routes=calls.splice(0); + window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})}; + const slowSave=CateriumCloudTransport.create({upstream,proxy,timeout:10,writeTimeout:100}); + const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0); + const timeoutSend=CateriumCloudTransport.create({upstream,proxy,writeTimeout:5});let timeoutName=''; + try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0); + const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel=''; + try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message} + return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls}; + }); + expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','backend.example.invalid','backend.example.invalid']); + expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1); + expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0); +}); + +async function syncHarness(page){ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''}));await page.goto('/index.html'); + await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); + const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); + source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`); + await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); +} + +test('an uncertain save is recovered by reading its committed revision without a duplicate write',async({page})=>{ + await syncHarness(page);await page.clock.install(); + await page.evaluate(async()=>{ + window.calls=[];window.toasts=[];window.SunEnterprise={toast:message=>window.toasts.push(message)}; + const payload={format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]}}};let row={payload,revision:1}; + localStorage.setItem('sunOrders',JSON.stringify([{id:'test-order',total:2200}])); + SunCloudV2.testInit({rpc:async(name,args)=>{calls.push(name);if(name==='sun_fetch_app_state')return {data:[structuredClone(row)]};if(name==='sun_save_app_state_v17'){row={payload:structuredClone(args.p_payload),revision:2};return {error:{message:'TimeoutError: server response lost'}}}throw new Error(name)}}); + await SunCloudV2.testBaseline({payload,revision:1});await SunCloudV2.syncNow(); + }); + expect(await page.evaluate(()=>SunCloudV2.status().lastStatus)).toBe('pending'); + await page.clock.fastForward(2100); + await expect.poll(()=>page.evaluate(()=>SunCloudV2.status().lastStatus)).toBe('ready'); + const result=await page.evaluate(()=>({calls,toasts,orders:JSON.parse(localStorage.sunOrders)})); + expect(result.calls.filter(x=>x==='sun_fetch_app_state')).toHaveLength(2);expect(result.calls.filter(x=>x==='sun_save_app_state_v17')).toHaveLength(1); + expect(result.toasts).toEqual([]);expect(result.orders).toEqual([{id:'test-order',total:2200}]); +}); + +test('a late sync response cannot apply the previous account data after sign-out',async({page})=>{ + await syncHarness(page); + const result=await page.evaluate(async()=>{ + window.toasts=[];window.SunEnterprise={toast:message=>toasts.push(message)};let resolve; + SunCloudV2.testInit({rpc:()=>new Promise(r=>resolve=r)}); + const pending=SunCloudV2.syncNow();SunCloudV2.testLeave();resolve({data:[{revision:2,payload:{storage:{sunOrders:{t:'j',v:[{id:'previous-user'}]}}}}]});await pending; + return {orders:localStorage.getItem('sunOrders'),toasts,workspace:SunCloudV2.getWorkspace()}; + }); + expect(result.orders).toBeNull();expect(result.toasts).toEqual([]);expect(result.workspace).toBeNull(); +}); + test('cloud reads and password login recover from empty proxy responses without replaying writes',async({page})=>{ await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); const result=await page.evaluate(async()=>{ const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read'; window.fetch=async request=>{calls.push({url:request.url,body:await request.text(),auth:request.headers.get('authorization')});if(scenario==='denied')return new Response('{"error":"denied"}',{status:401});if(scenario==='write')return new Response('',{status:503});return request.url.startsWith(proxy)?new Response('',{headers:{'content-type':'text/html'}}):new Response('[{"id":"company"}]',{headers:{'content-type':'application/json'}})}; - const send=window.CateriumCloudTransport.create({upstream,proxy}); + const send=window.CateriumCloudTransport.create({upstream,proxy,cooldown:0}); const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json(); const readCalls=calls.splice(0); await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 21759a2..ce90953 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v92-20260918-proposal-quality')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); -check(index.includes('20260918-proposal-quality')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); +check(sw.includes('v93-20260918-sync-demo-images')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); +check(index.includes('20260918-sync-demo-images')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); @@ -39,9 +39,9 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30 check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.channel==='production','release manifest channel is production'); -check(String(releaseManifest.pwaCache||'').includes('v92-20260918-proposal-quality'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v93-20260918-sync-demo-images'),'release manifest points to current PWA cache'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); -check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); +check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); check(runtime.includes('refreshSupportWorkspace')&&runtime.includes('sun_dev_support_snapshot'),'cloud exposes lightweight read-only support refresh'); check(runtime.includes('DEV_ADMIN_TTL_MS=30000')&&hotfix.includes('checkPlatformAdmin?.(false)')&&hotfix.includes('},10000);'),'developer access checks are throttled'); @@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(pkg.version==='17.7.3','package version is v17.7.3'); -check(index.includes('20260918-proposal-quality'),'index cache bust is v17.7.3'); -check(sw.includes('v92-20260918-proposal-quality')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); +check(index.includes('20260918-sync-demo-images'),'index cache bust is v17.7.3'); +check(sw.includes('v93-20260918-sync-demo-images')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index ea9f98c..d28d97d 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('20260918-proposal-quality')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); -if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-proposal-quality')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); +if(!sw.includes('20260918-sync-demo-images')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); +if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-sync-demo-images')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed'); @@ -44,7 +44,7 @@ if(!performance.includes('ops-ux-v1762.js')||!performance.includes('SunOpsUXV176 for(const marker of ['patchDeveloperOpen','enhanceDeveloperGate','data-saas-admin','stopImmediatePropagation','instanceof HTMLElement']) if(!hotfix.includes(marker))fail(`developer/SaaS hotfix marker missing: ${marker}`);else ok(`developer/SaaS hotfix marker: ${marker}`); for(const marker of ['SUPPORT_POLL_MS=20000','supportReadPermission','refreshSupportWorkspace','sun-menu-editor-v1762','showCalendarDay',"ROUTE_BASE_KEY='sunRouteBaseV1'",'showRouteOrder','routeOpenYandex']) if(!ops.includes(marker)) fail(`ops UX marker missing: ${marker}`); else ok(`ops UX marker: ${marker}`); for(const marker of ["AUTO_DELAY_MS=60*1000","order.prepayment=total","order.status='Отдан заказчику'",'sunAutoCompletedAt','classificationDate','persistOfferTemplate','clientOfferTemplateId','offerTemplateId','sun-v1764-menu-icon','CateriumServerAutomationV1770?.enabled']) if(!ux.includes(marker))fail(`UX compatibility marker missing: ${marker}`);else ok(`UX compatibility marker: ${marker}`); -for(const marker of ['CLOUD_RPC_TIMEOUT_MS=12000','CLOUD_CONFLICT_MAX_RETRIES=4','refreshSupportWorkspace',"const VERSION = '17.7.3'",'ERROR_DEDUPE_MS=5*60*1000','DEV_ADMIN_TTL_MS=30000']) if(!runtime.includes(marker))fail(`stability marker missing: ${marker}`);else ok(`stability marker: ${marker}`); +for(const marker of ['CLOUD_RPC_TIMEOUT_MS=45000','CLOUD_CONFLICT_MAX_RETRIES=4','refreshSupportWorkspace',"const VERSION = '17.7.3'",'ERROR_DEDUPE_MS=5*60*1000','DEV_ADMIN_TTL_MS=30000']) if(!runtime.includes(marker))fail(`stability marker missing: ${marker}`);else ok(`stability marker: ${marker}`); if(!hotfix.includes('checkPlatformAdmin?.(false)')||!hotfix.includes('},10000);'))fail('Developer fallback polling is still aggressive');else ok('Developer fallback polling is throttled'); if(!ux.includes('sunMenuIconV1766')||!ux.includes('sun-offer-template-mini-editorial-grid')||!runtime.includes('explicitTemplate'))fail('v17.6.6 proposal/menu markers missing');else ok('v17.6.6 proposal/menu markers present'); if(!classic.includes("const VERSION='17.6.7'")||!classic.includes('CLASSIC_IDS')||!classic.includes('ARCHIVE_IDS')||!classic.includes('renderPages'))fail('v17.6.7 classic PDF module missing');else ok('v17.6.7 classic PDF module present'); diff --git a/tests/trial-demo.spec.mjs b/tests/trial-demo.spec.mjs index 790459b..0ac6aef 100644 --- a/tests/trial-demo.spec.mjs +++ b/tests/trial-demo.spec.mjs @@ -40,7 +40,11 @@ test('demo TTK, order, shortages, receipt and one-time write-off work in the ful }); test('demo photo assets load and company changes remove the trial guide',async({page})=>{ await ready(page); - expect(await page.evaluate(async d=>{const images=await Promise.all(d.boxes.map(b=>new Promise(resolve=>{const i=new Image();i.onload=()=>resolve(i.naturalWidth>500);i.onerror=()=>resolve(false);i.src=b.photo})));return images.every(Boolean)},demo)).toBe(true); + expect(await page.evaluate(async d=>{const images=await Promise.all(d.boxes.map(b=>new Promise(resolve=>{const i=new Image();i.onload=()=>resolve(i.naturalWidth===1024);i.onerror=()=>resolve(false);i.src=SunSafe.imageAssetSrc(b.photo)})));return images.every(Boolean)},demo)).toBe(true); + const sources=await page.locator('#tiles img[src*="demo/images/"]').evaluateAll(images=>images.map(i=>i.getAttribute('src'))); + expect(sources).toHaveLength(10);expect(sources.every(s=>s.endsWith('.webp'))).toBe(true); + const bytes=demo.boxes.reduce((sum,b)=>sum+fs.statSync('public/'+b.photo.replace('.png','.webp')).size,0);expect(bytes).toBeLessThan(1500000); + expect(await page.evaluate(()=>['catalog/001.jpg','data:image/png;base64,AAAA','https://other.example/demo/images/caprese.png','demo/images/custom.png'].map(SunSafe.imageAssetSrc))).toEqual(['catalog/001.jpg','data:image/png;base64,AAAA','https://other.example/demo/images/caprese.png','demo/images/custom.png']); await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));window.SunCloudV2.getSession=()=>null;localStorage.removeItem('sunTrialDemoV1');window.CateriumTrialDemo.render()}); await expect(page.locator('#ctTrialDemo')).toHaveCount(0); });