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=>`${item.photo?` `:("")}${esc(item.name)} ${item.catalogSection?`${esc(item.catalogSection)} `:''}${(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''} `:''}${money(item.price||0)} ${Number(item.oldPrice||0)>Number(item.price||0)?`${money(item.oldPrice)} `:''} ${Number(item.oldPrice||0)>Number(item.price||0)?`АКЦИЯ `:''} `).join('');
+ const cards=items.map(item=>`${item.photo?` `:("")}${esc(item.name)} ${item.catalogSection?`${esc(item.catalogSection)} `:''}${(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''} `:''}${money(item.price||0)} ${Number(item.oldPrice||0)>Number(item.price||0)?`${money(item.oldPrice)} `:''} ${Number(item.oldPrice||0)>Number(item.price||0)?`АКЦИЯ `:''} `).join('');
const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');
const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`;
$('tiles').innerHTML=`+ ${addText} ${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=>`${item.photo?` `:("")}${esc(item.name)} ${money(item.price||0)} ${Number(item.oldPrice||0)>Number(item.price||0)?`${money(item.oldPrice)} `:''} ${(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''} `:''}${cat.prep?`${(item.ingredients||[]).length} позиций в составе `:''} `).join(''):'Ничего не найдено.
';
+ $('managerList').innerHTML=items.length?items.map(item=>`${item.photo?` `:("")}${esc(item.name)} ${money(item.price||0)} ${Number(item.oldPrice||0)>Number(item.price||0)?`${money(item.oldPrice)} `:''} ${(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''} `:''}${cat.prep?`${(item.ingredients||[]).length} позиций в составе `:''} `).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);
});