diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index fb799d7e..8cb3282a 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -170,3 +170,14 @@ Final1000WAN:134chars/86.5px высоты summary; WAN10:130chars/86.5px. В т 2026-10-04, UI/source расчёта не менялись. Child script принимает integer count20–1000, hard timeout10с, без API/DB/браузера/сети.7WAN×1000: equal1217.5ms (IDs p0..6), varied7967.5ms (p13..19), freshness не завершился за10с, процесс остановлен. Startup отдельно ~0.4s. Freshness создаёт1000 разных возрастов, будущие timestamps не используются. ESLint скрипта прошёл; корректность ID проверяется после успешного завершения. Это оставшийся CPU риск, а не performance приёмка. В current DFS повторяются Date.parse/trim/score и оценка upper для ветвей, уже достигших максимум. Следующий12.3.66: score cache только для bounded WAN≤7 DFS с одним snapshot time и early stop при достижении node upper, preserving first tie/order/forced picks/greedy fallback. Проверить1200 legacy oracle fixtures, raw observations, profiles20/1000. Худшая комбинаторика и весь full-route product по-прежнему не ограничены. + + +## 12.3.66 — точный выбор проб из большого pool + +2026-10-07. Score matrix вычисляется только при exact DFS≤7WAN/pool≥WAN, с единым временем ранжирования на назначение JH. Сырые наблюдения/даты не меняются, greedy fallback сохранён. Допустимый score-first greedy путь даёт только lower total, не готовое назначение. Пока DFS не нашёл реальный witness, равные верхние границы НЕ отсекаются; первое равное назначение остаётся в исходном pool order. Node stop требует реального bestAssign. Для каждого кандидата child upper выводится из topK максимумов текущего узла с исключением занятой пробы; уже проигрышные siblings не пересканируют весь pool. Forced pick сохраняется, даже если ветвь отсечена. + +1230 seeded сравнений с независимым прежним DFS совпали по точным probe IDs, включая30 смешанных наборов7WAN.10 pure tests прошли: ties/partial matching, single snapshot при переходе часа, прежний greedy для8WAN, unknown/zero/disabled/raw snapshots.51 main browser и8 context прошли; TypeScript без incremental, targeted lint и production build успешны. Historical9 optimizer PNG восстановлены по точному списку. + +Pure child CPU1000: equal1217.5→15.6ms,varied7967.5→14.5ms,freshness10с timeout→16.7ms; CPU20:11.5–11.7ms. Child startup отдельно~0.25s. Exact IDs проверены во всех6 профилях. Evidence docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json содержит source SHA256, baseline HEAD и результаты. Браузерная live ветка7WAN/1000probes:219902bytes/7rows/222tableDOM/1018ms dev-ready; timestamps соответствуют p993..999, после refresh сохраняются,320px без overflow, API mutations отсутствуют. Это не production SLA/нагрузка PostgreSQL/RouterOS и не гарантия произвольного mixed pool: худшая экспоненциальная сложность DFS и full-route product остаются. + +Следующий12.3.67: KPI captions JH/Exit (повторы/пустая площадка/unknown), затем separate full-route CPU profile. Общая main/context регрессия12.4.6/7 относится к прежним HEAD; этот delta принят профильными51+8. Внешняя12.5 не выполнена. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 842dd8af..cc1c3120 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.65 baseline7WAN×1000pool сохранён: equal1218ms,varied7968ms,freshness timeout10с. Следующий12.3.66: кешировать score пары WAN/probe на одном времени снимка только в DFS≤7, безопасно завершать ветвь при достижении её upper bound; доказать exact IDs/ties и повторить20/1000 child profiles. Не запускать тяжёлый pool в UI. Внешняя12.5 остаётся. +Актуальное продолжение 2026-10-07: 12.3.66 завершён: DFS score cache/one-time snapshot/lower-bound/child-bound, exact1230 fixtures,10pure+51main+8context/build passed. CPU1000 equal15.6ms,varied14.5ms,freshness16.7ms вместо10с timeout. Следующий12.3.67: краткие KPI captions JH/Exit с честными площадками/unknown. Затем отдельный CPU-профиль full-route product; внешняя12.5 остаётся. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index c57c89f3..0f3d0c3f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.65 baseline7WAN×1000pool сохранён: equal1218ms,varied7968ms,freshness timeout10с. Следующий12.3.66: кешировать score пары WAN/probe на одном времени снимка только в DFS≤7, безопасно завершать ветвь при достижении её upper bound; доказать exact IDs/ties и повторить20/1000 child profiles. Не запускать тяжёлый pool в UI. Внешняя12.5 остаётся. +Актуальное продолжение 2026-10-07: 12.3.66 завершён: DFS score cache/one-time snapshot/lower-bound/child-bound, exact1230 fixtures,10pure+51main+8context/build passed. CPU1000 equal15.6ms,varied14.5ms,freshness16.7ms вместо10с timeout. Следующий12.3.67: краткие KPI captions JH/Exit с честными площадками/unknown. Затем отдельный CPU-профиль full-route product; внешняя12.5 остаётся. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 6451d06e..da8bc2dd 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -747,3 +747,14 @@ Final1000WAN:134chars/86.5px высоты summary; WAN10:130chars/86.5px. В т 2026-10-04, UI/source расчёта не менялись. Child script принимает integer count20–1000, hard timeout10с, без API/DB/браузера/сети.7WAN×1000: equal1217.5ms (IDs p0..6), varied7967.5ms (p13..19), freshness не завершился за10с, процесс остановлен. Startup отдельно ~0.4s. Freshness создаёт1000 разных возрастов, будущие timestamps не используются. ESLint скрипта прошёл; корректность ID проверяется после успешного завершения. Это оставшийся CPU риск, а не performance приёмка. В current DFS повторяются Date.parse/trim/score и оценка upper для ветвей, уже достигших максимум. Следующий12.3.66: score cache только для bounded WAN≤7 DFS с одним snapshot time и early stop при достижении node upper, preserving first tie/order/forced picks/greedy fallback. Проверить1200 legacy oracle fixtures, raw observations, profiles20/1000. Худшая комбинаторика и весь full-route product по-прежнему не ограничены. + + +## 12.3.66 — точный выбор проб из большого pool + +2026-10-07. Score matrix вычисляется только при exact DFS≤7WAN/pool≥WAN, с единым временем ранжирования на назначение JH. Сырые наблюдения/даты не меняются, greedy fallback сохранён. Допустимый score-first greedy путь даёт только lower total, не готовое назначение. Пока DFS не нашёл реальный witness, равные верхние границы НЕ отсекаются; первое равное назначение остаётся в исходном pool order. Node stop требует реального bestAssign. Для каждого кандидата child upper выводится из topK максимумов текущего узла с исключением занятой пробы; уже проигрышные siblings не пересканируют весь pool. Forced pick сохраняется, даже если ветвь отсечена. + +1230 seeded сравнений с независимым прежним DFS совпали по точным probe IDs, включая30 смешанных наборов7WAN.10 pure tests прошли: ties/partial matching, single snapshot при переходе часа, прежний greedy для8WAN, unknown/zero/disabled/raw snapshots.51 main browser и8 context прошли; TypeScript без incremental, targeted lint и production build успешны. Historical9 optimizer PNG восстановлены по точному списку. + +Pure child CPU1000: equal1217.5→15.6ms,varied7967.5→14.5ms,freshness10с timeout→16.7ms; CPU20:11.5–11.7ms. Child startup отдельно~0.25s. Exact IDs проверены во всех6 профилях. Evidence docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json содержит source SHA256, baseline HEAD и результаты. Браузерная live ветка7WAN/1000probes:219902bytes/7rows/222tableDOM/1018ms dev-ready; timestamps соответствуют p993..999, после refresh сохраняются,320px без overflow, API mutations отсутствуют. Это не production SLA/нагрузка PostgreSQL/RouterOS и не гарантия произвольного mixed pool: худшая экспоненциальная сложность DFS и full-route product остаются. + +Следующий12.3.67: KPI captions JH/Exit (повторы/пустая площадка/unknown), затем separate full-route CPU profile. Общая main/context регрессия12.4.6/7 относится к прежним HEAD; этот delta принят профильными51+8. Внешняя12.5 не выполнена. diff --git a/docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json b/docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json new file mode 100644 index 00000000..dec3f0de --- /dev/null +++ b/docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json @@ -0,0 +1,166 @@ +{ + "baseHead": "c6dfa5267b210b01fe72cbac7b310d3960a6a131", + "sourceSha256": "53273273c46f78cde2c82cff52b282dd19dcfc5c363f0b64c0b50cac44e20510", + "environment": "Pure calculation child process, no API/database/network", + "runs": [ + { + "variant": "equal", + "wanCount": 7, + "probeCount": 20, + "timeoutMs": 10000, + "elapsedMs": 273, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 11.667099999999976, + "legs": 7, + "probeIds": [ + "p0", + "p1", + "p2", + "p3", + "p4", + "p5", + "p6" + ] + }, + "error": "" + }, + { + "variant": "varied", + "wanCount": 7, + "probeCount": 20, + "timeoutMs": 10000, + "elapsedMs": 245, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 11.477599999999995, + "legs": 7, + "probeIds": [ + "p13", + "p14", + "p15", + "p16", + "p17", + "p18", + "p19" + ] + }, + "error": "" + }, + { + "variant": "freshness", + "wanCount": 7, + "probeCount": 20, + "timeoutMs": 10000, + "elapsedMs": 275, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 11.482100000000003, + "legs": 7, + "probeIds": [ + "p13", + "p14", + "p15", + "p16", + "p17", + "p18", + "p19" + ] + }, + "error": "" + }, + { + "variant": "equal", + "wanCount": 7, + "probeCount": 1000, + "timeoutMs": 10000, + "elapsedMs": 247, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 15.580399999999997, + "legs": 7, + "probeIds": [ + "p0", + "p1", + "p2", + "p3", + "p4", + "p5", + "p6" + ] + }, + "error": "" + }, + { + "variant": "varied", + "wanCount": 7, + "probeCount": 1000, + "timeoutMs": 10000, + "elapsedMs": 244, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 14.4709, + "legs": 7, + "probeIds": [ + "p13", + "p14", + "p15", + "p16", + "p17", + "p18", + "p19" + ] + }, + "error": "" + }, + { + "variant": "freshness", + "wanCount": 7, + "probeCount": 1000, + "timeoutMs": 10000, + "elapsedMs": 242, + "status": 0, + "timedOut": false, + "result": { + "elapsedMs": 16.685599999999994, + "legs": 7, + "probeIds": [ + "p993", + "p994", + "p995", + "p996", + "p997", + "p998", + "p999" + ] + }, + "error": "" + } + ], + "browserProfiles": { + "source": "Playwright stdout, live API fixture", + "passed": 51, + "contextPassed": 8, + "profiles": [ + { + "poolCount": 20, + "probeBytes": 4382, + "rendered": 7, + "tableNodes": 222, + "readyMs": 1293 + }, + { + "poolCount": 1000, + "probeBytes": 219902, + "rendered": 7, + "tableNodes": 222, + "readyMs": 1018 + } + ] + } +} diff --git a/lib/route-optimizer-assignment.test.ts b/lib/route-optimizer-assignment.test.ts index f2cdde58..3046ce75 100644 --- a/lib/route-optimizer-assignment.test.ts +++ b/lib/route-optimizer-assignment.test.ts @@ -35,8 +35,8 @@ test("branch bound preserves legacy assignments for ties, incompatibility and pa const now=Date.parse("2026-10-04T12:00:00Z");context.mock.method(Date,"now",()=>now) let seed=917;const random=(n:number)=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return (seed>>>8)%n} const names=["","ether1","ether2"," ETHER1 "] - for(let fixture=0;fixture<1200;fixture++){ - const count=1+random(4),wans=Array.from({length:count},()=>names[1+random(names.length-1)]) + for(let fixture=0;fixture<1230;fixture++){ + const count=fixture<1200?1+random(4):7,wans=Array.from({length:count},()=>names[1+random(names.length-1)]) const pool=Array.from({length:count+random(3)},(_,i)=>({...probe(i,names[random(names.length)]),lastPingRttMs:random(2)?0:null,lastTxAvgMbps:random(2)?100:null,lastRxAvgMbps:random(2)?100:null,lastPingAt:random(3)?null:"2026-10-04T10:00:00Z"})) assert.deepEqual(chosen(wans,pool),legacy(wans,pool,now),`fixture ${fixture}`) } @@ -51,3 +51,15 @@ test("early forced choice remains unchanged when a later WAN loses its compatibl const wans=["ether1", "ether1"],pool=[probe(0,"ether1"),probe(1,"ether2")] assert.deepEqual(chosen(wans,pool),["p0",null]) }) + + +test("DFS uses one ranking time when the clock crosses an hour boundary",context=>{ + const at=Date.parse('2026-10-07T12:00:00Z');let calls=0;context.mock.method(Date,'now',()=>at+calls++*3600000) + const pool=Array.from({length:3},(_,i)=>({...probe(i,''),lastPingAt:new Date(at-(2-i)*3600000).toISOString()})) + assert.deepEqual(chosen(['ether1','ether2'],pool),legacy(['ether1','ether2'],pool,at)) +}) + +test("greedy fallback remains unchanged for eight WANs and a short pool",()=>{ + const wans=Array.from({length:8},()=> 'ether1'),pool=[probe(0,'ether2'),probe(1,'ether1'),probe(2,''),probe(3,'')] + assert.deepEqual(chosen(wans,pool),['p1','p2','p3',null,null,null,null,null]) +}) diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts index 61724aa8..8ee3df4c 100644 --- a/lib/route-optimizer-data.ts +++ b/lib/route-optimizer-data.ts @@ -307,7 +307,7 @@ export function buildLiveOptimizerData( const probesForHome = speedProbes .filter((p) => p.enabled !== false && p.srcServerId === home.id) - function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string): number { + function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string, at?: number): number { const want = wanIface.trim().toLowerCase() const got = String(p.srcInterface ?? "").trim().toLowerCase() if (got && want && got !== want) return -1 @@ -317,7 +317,7 @@ export function buildLiveOptimizerData( : (!want && got) ? 15 : 5 const freshness = - p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 + p.lastPingAt ? Math.max(0, 20 - Math.floor(((at ?? Date.now()) - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 return ifaceScore + (p.lastPingRttMs != null ? 20 : 0) + (p.lastTxAvgMbps != null ? 10 : 0) @@ -336,56 +336,90 @@ export function buildLiveOptimizerData( let bestSum = -Infinity let bestAssign: Map | null = null const wanList = [...home.wans] + const shouldSearch = wanList.length <= 7 && wanList.length <= pool.length + // Only the bounded-WAN exact search needs this matrix. A single time + // snapshot keeps rankings stable if a calculation crosses an hour boundary. + const scored = shouldSearch ? (() => { + const at = Date.now() + return pool.map(probe => ({ probe, values: wanList.map(wan => scoreProbeForWanIface(probe, wan.iface, at)) })) + })() : [] + + // A score-first greedy path is a valid leaf of the same forced-pick tree. + // Seed only its total, never its assignment: the original DFS must still + // visit the first equal leaf, so ties keep the original pool order. + if (shouldSearch && scored.every(candidate => candidate.values.every(Number.isFinite))) { + const used = new Set() + let lower = 0 + for (let i = 0; i < wanList.length; i++) { + let best: (typeof scored)[number] | undefined + let maximum = -1 + for (const candidate of scored) { + if (!used.has(candidate.probe.id) && candidate.values[i]! > maximum) { + best = candidate + maximum = candidate.values[i]! + } + } + if (best) { used.add(best.probe.id); lower += maximum } + } + bestSum = lower + } function dfs(i: number, used: Set, cur: Map, sum: number) { if (i === wanList.length) { - if (sum > bestSum) { + if (sum > bestSum || (!bestAssign && sum === bestSum)) { bestSum = sum bestAssign = new Map(cur) } return } - if (bestAssign) { - // Ignore competition for probes: independent maxima are an upper bound - // on every possible completion, including a WAN with no compatible probe. - let upper = sum - const candidateMax = new Map() - for (let next = i; next < wanList.length; next++) { - let maximum = 0 - for (const candidate of pool) { - if (used.has(candidate.id)) continue - const value = scoreProbeForWanIface(candidate, wanList[next]!.iface) - maximum = Math.max(maximum, value) - candidateMax.set(candidate.id, Math.max(candidateMax.get(candidate.id) ?? 0, value)) - } - upper += maximum + // Independent WAN maxima and the highest distinct-probe maxima are + // both upper bounds: each completion picks at most one probe per WAN. + let upper = sum + const candidateMax = new Map() + for (let next = i; next < wanList.length; next++) { + let maximum = 0 + for (const candidate of scored) { + if (used.has(candidate.probe.id)) continue + const value = candidate.values[next]! + maximum = Math.max(maximum, value) + candidateMax.set(candidate.probe.id, Math.max(candidateMax.get(candidate.probe.id) ?? 0, value)) } - // A completion uses at most one distinct probe per remaining WAN. - // Its weight is bounded by the highest remaining probe maxima, even - // when those maxima cannot actually be assigned to different WANs. - const distinctUpper = sum + [...candidateMax.values()].sort((a, b) => b - a) - .slice(0, wanList.length - i).reduce((total, value) => total + value, 0) - upper = Math.min(upper, distinctUpper) - // Equal totals keep the first assignment in the original DFS order. - if (upper <= bestSum) return + upper += maximum } + const remaining = wanList.length - i + const top = [...candidateMax.entries()].sort((a, b) => b[1] - a[1]).slice(0, remaining) + const topSum = top.reduce((total, [, value]) => total + value, 0) + const topById = new Map(top) + const distinctUpper = sum + topSum + upper = Math.min(upper, distinctUpper) + // Equal totals keep the first assignment in the original DFS order. + if (upper < bestSum || (bestAssign && upper <= bestSum)) return const wan = wanList[i]! let picked = false - for (const p of pool) { + for (const candidate of scored) { + const p = candidate.probe if (used.has(p.id)) continue - const sc = scoreProbeForWanIface(p, wan.iface) + const sc = candidate.values[i]! if (sc < 0) continue picked = true + // Relax the child using this node's probe maxima. Removing a top + // probe leaves K-1 entries; otherwise remove the lowest of the top K. + // This avoids rescanning the whole pool for an already losing sibling. + const removed = topById.get(p.id) ?? (top.length === remaining ? top.at(-1)![1] : 0) + const childUpper = sum + sc + topSum - removed + if (childUpper < bestSum || (bestAssign && childUpper <= bestSum)) continue used.add(p.id) cur.set(wan.id, p) dfs(i + 1, used, cur, sum + sc) cur.delete(wan.id) used.delete(p.id) + // No remaining sibling can improve a result reaching this node bound. + if (bestAssign && bestSum >= upper) return } if (!picked) dfs(i + 1, used, cur, sum) } - if (wanList.length <= 7 && wanList.length <= pool.length) { + if (shouldSearch) { dfs(0, new Set(), new Map(), 0) } diff --git a/tests/e2e/large-optimizer-probes.spec.ts b/tests/e2e/large-optimizer-probes.spec.ts new file mode 100644 index 00000000..c4972b23 --- /dev/null +++ b/tests/e2e/large-optimizer-probes.spec.ts @@ -0,0 +1,40 @@ +import {test,expect} from "@playwright/test" +import {writeFileSync} from "node:fs" +import {optimizerServersSchema,optimizerProbesSchema} from "../../lib/route-optimizer-response" + +for(const poolCount of [20,1000])test(`Оптимизатор7WAN: ранжирование ${poolCount} проб по свежести`,async({page},info)=>{ + test.setTimeout(90000) + const at=Date.now() + const probes=optimizerProbesSchema.parse({probes:Array.from({length:poolCount},(_,i)=>({id:`p${i}`,srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastPingRttMs:10,lastPingLossPct:0,lastTxAvgMbps:100,lastRxAvgMbps:100,lastPingAt:new Date(at-(poolCount-i)*3600000).toISOString()}))}) + const common = { host: "192.0.2.1", enabled: true, status: "online", latency: 10, country: "RU", site: "TEST", model: "CHR" } + const servers = optimizerServersSchema.parse([ + { ...common, id: 1, name: "Домашний роутер", type: "home-router", wanUplinks: Array.from({ length: 7 }, (_, i) => ({ id: `wan${i+1}`, name: `WAN${i+1}`, isp: "ISP", iface: `ether${i+1}`, ip: `198.51.100.${i+1}`, maxDl: 100, maxUl: 100 })) }, + ...Array.from({ length: 1 }, (_, i) => ({ ...common, id: i+2, name: `Jump ${i+1}`, type: "jump-host" })), + ]) + + + let reads=0 + const writes: string[] = [] + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if(path==="/api/uptime/speed-probes")reads++ + + + if (r.request().method() !== "GET" && !path.endsWith("/preview")) writes.push(path) + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? servers : path === "/api/filters/rules" ? {rulesets:[]} : path === "/api/uptime/speed-probes" ? probes : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : path.endsWith("/ospf/optimize/preview") ? { serverId: 1, serverName: "Домашний роутер", pingWeight: 60, interfacesTotal: 0, changedCount: 0, unchangedCount: 0, interfaces: [], changes: [] } : {} }) + }) + + + + const started=Date.now();await page.goto("/route-optimizer?server=1&keep=1") + const table=page.getByRole("table").filter({hasText:"WAN-аплинк"}),rows=table.locator("tr[data-row-id]") + await expect(rows).toHaveCount(7) + for(let i=0;i<7;i++)await expect(rows.nth(i).locator("time")).toHaveAttribute("datetime",probes.probes[poolCount-7+i].lastPingAt!) + const profile={poolCount,wanCount:7,probeBytes:Buffer.byteLength(JSON.stringify(probes)),rendered:await rows.count(),tableNodes:await table.locator("*").count(),readyMs:Date.now()-started} + console.log("Optimizer probes",JSON.stringify(profile));writeFileSync(info.outputPath("probes-profile.json"),JSON.stringify(profile,null,2)) + const before=reads;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>reads).toBeGreaterThan(before) + for(let i=0;i<7;i++)await expect(rows.nth(i).locator("time")).toHaveAttribute("datetime",probes.probes[poolCount-7+i].lastPingAt!) + await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) + expect(writes).toEqual([]) +})