From a404da768dd7e5f20131572bf7c2b523d8a98c82 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 13 Oct 2026 20:12:33 +0700 Subject: [PATCH] =?UTF-8?q?fix(network):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20WireGuard?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B0=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/filters/page.tsx | 4 +- app/(main)/firewall/page.tsx | 3 +- app/(main)/gre/page.tsx | 3 +- app/(main)/ipsec/page.tsx | 3 +- app/(main)/wireguard/page.tsx | 33 +- .../acceptance/wireguard-secrets-sql.mjs | 17 + backend/src/routes/wireguard.ts | 4 + components/data-grids/wireguard-data-grid.tsx | 16 +- docs/ux-ui-external-acceptance.md | 18 +- docs/ux-ui-plan.md | 6 +- docs/ux-ui-progress.md | 16 +- docs/ux-ui-remaining-acceptance.md | 22 +- docs/ux-ui-stage-12.md | 16 +- docs/ux-ui-test-server.md | 18 +- docs/ux-ui/acceptance/compiled208-result.json | 1054 +++++++++++++++++ .../native208-deployment-2026-10-13.json | 15 + .../acceptance/native208-e2e-2026-10-13.json | 18 + .../native208-live-ui-2026-10-13.json | 175 +++ .../acceptance/native208-profile-result.json | 18 + .../wireguard208-baseline-result.json | 82 ++ .../acceptance/wireguard208-fixed-result.json | 82 ++ .../wireguard208-native-live-2026-10-13.json | 42 + tests/e2e/network-readonly.spec.ts | 19 + 23 files changed, 1648 insertions(+), 36 deletions(-) create mode 100644 backend/scripts/acceptance/wireguard-secrets-sql.mjs create mode 100644 docs/ux-ui/acceptance/compiled208-result.json create mode 100644 docs/ux-ui/acceptance/native208-deployment-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/native208-e2e-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/native208-live-ui-2026-10-13.json create mode 100644 docs/ux-ui/acceptance/native208-profile-result.json create mode 100644 docs/ux-ui/acceptance/wireguard208-baseline-result.json create mode 100644 docs/ux-ui/acceptance/wireguard208-fixed-result.json create mode 100644 docs/ux-ui/acceptance/wireguard208-native-live-2026-10-13.json create mode 100644 tests/e2e/network-readonly.spec.ts diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index 65c8d3d9..b2fd1bd8 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -1497,7 +1497,7 @@ function FiltersPage() { const allTunnels = !isLive ? greTunnels : (greByServer[selectedServerId] ?? []) const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) - const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle")) + const mutationsLocked = (isLive && !can("mm:filters:write")) || movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle")) const filterRailItems = useMemo(() => ( allServers.map((s) => ({ @@ -1548,6 +1548,7 @@ function FiltersPage() { nextRules: FilterRule[] | (() => Promise), source: "apply" | "copy" = "apply", ) => { + if (isLive && !can("mm:filters:write")) return { ok: false, error: "Нет прав на изменение фильтров" } if (!mounted.current) return { ok: false, error: "Источник данных изменён" } if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" } if (isLive && (liveStale || liveLoadState !== "idle" || (serverId === selectedServerId && readyServerId !== serverId))) { @@ -1864,6 +1865,7 @@ function FiltersPage() { } banner={ <> + {isLive && !can("mm:filters:write") &&

Режим просмотра. Изменение фильтров недоступно.

} {isLive && liveLoadState === "error" && (
diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index 58022ef1..ce534a0c 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -2169,7 +2169,7 @@ function FirewallPageInner() { const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : [] const firewallIncomplete = scopedFailures.length > 0 - const firewallWriteBlocked = firewallIncomplete || (isLive && !readTrusted) + const firewallWriteBlocked = (isLive && !can("mm:network:write")) || firewallIncomplete || (isLive && !readTrusted) const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily) const freshness = firewallTimeRange(scopedSliceTimes) const totalEnabled = familyRules.filter((r) => r.enabled).length @@ -2530,6 +2530,7 @@ function FirewallPageInner() { showAll allCount={displayServers.length} loading={isLive && dataLoading && displayServers.length === 0} + banner={isLive && !can("mm:network:write") ?

Режим просмотра. Изменение конфигурации недоступно.

: undefined} header={ s.id === serverId && s.enabled) } @@ -548,6 +548,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on showAll allCount={displayServers.length} loading={isLive && dataLoading && displayServers.length === 0} + banner={isLive && !can("mm:network:write") ?

Режим просмотра. Изменение конфигурации недоступно.

: undefined} header={ f.serverId)) const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) - const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError && + const canManage = (serverId: string) => isLive && can("mm:network:write") && hasLoaded && !dataError && !inventoryError && !loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId) const assertDeviceReady = (serverId: string) => { if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec") @@ -637,6 +637,7 @@ function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWor showAll allCount={displayClients.length} loading={isLive && loading && displayServers.length === 0} + banner={isLive && !can("mm:network:write") ?

Режим просмотра. Изменение конфигурации недоступно.

: undefined} header={ ([]) const [liveServers, setLiveServers] = useState([]) @@ -446,6 +447,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf setCreateError(null) setBusy(true) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") const ep = parseEndpoint(form.peerEndpoint) await createWireGuardInterface(backendUrl, { serverId: form.serverId, @@ -495,6 +497,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf setImportError(null) setBusy(true) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") const res = await importWireGuard(backendUrl, { serverId: args.serverId, content: args.content, @@ -538,6 +541,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf setTogglePending(context) setToggleError(null) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { disabled: iface.enabled, }) @@ -564,6 +568,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf setDeleteBusy(true) setDeleteError(null) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") if (pendingDelete.kind === "iface") { if (!pendingDelete.iface.rosId) { if (active.current) toast.info("Доступно только в live-режиме") @@ -599,6 +604,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf setPeerError(null) setBusy(true) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") const ep = parseEndpoint(form.endpoint) await createWireGuardPeer(backendUrl, { serverId: peerIface.serverId, @@ -630,6 +636,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf exportPending.current = epoch setExportBusy(true) try { + if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей") const peer = format === "peer-conf" ? selectWgExportPeer(exportIface.peers, exportPeerId) : undefined if (format === "peer-conf" && !peer) throw new Error("Выбранный пир не найден. Обновите список и выберите пира заново.") const res = await exportWireGuard(backendUrl, { @@ -671,7 +678,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf } const emptyCreateAction = ( - @@ -687,6 +694,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf showAll allCount={completeScope ? displayIfaces.length : undefined} loading={isLive && loading && displayServers.length === 0} + banner={!canWrite ?

Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.

: undefined} header={ )} - - @@ -843,15 +851,16 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf /> {catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? openExport(iface, "rsc")} - onAddPeer={selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }} - onToggle={selectedSourceError ? undefined : handleToggle} + onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }} + onToggle={!canWrite || selectedSourceError ? undefined : handleToggle} toggleBusy={togglePending !== null} - onDelete={selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })} - onDeletePeer={selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} - onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} + onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })} + onDeletePeer={!canWrite || selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} + onExportPeer={canWrite ? (iface, peerId) => openExport(iface, "peer", peerId) : undefined} /> :

Каталог интерфейсов пока неизвестен

} @@ -869,22 +878,22 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf compactServer={compactServer} emptyAction={ scopedIfaces.length === 1 ? ( - ) : emptyCreateAction } - onDeletePeer={selectedSourceError ? undefined : (row) => { + onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => { const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId) if (!iface) return setPendingDelete({ kind: "peer", iface, peerId: row.id }) }} - onExportPeer={(row) => { + onExportPeer={canWrite ? (row) => { const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId) if (!iface) return openExport(iface, "peer", row.id) - }} + } : undefined} /> :

Каталог пиров пока неизвестен

} diff --git a/backend/scripts/acceptance/wireguard-secrets-sql.mjs b/backend/scripts/acceptance/wireguard-secrets-sql.mjs new file mode 100644 index 00000000..bdd2276d --- /dev/null +++ b/backend/scripts/acceptance/wireguard-secrets-sql.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import {createServer} from 'node:http' +import {randomBytes} from 'node:crypto' +const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL) +assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database) +process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own208.invalid';process.env.AUTH_PORTAL_URL='https://own208.invalid' +const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js') +assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false)) +const key=randomBytes(32).toString('base64'),publicKey=randomBytes(32).toString('base64'),baseline=process.env.MMAPP_WG_SECRET_EXPECT==='leak',cases=[];let calls=0,app +const native=createServer((req,res)=>{calls++;assert.equal(req.method,'GET');res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url.endsWith('/interface/wireguard')?[{'.id':'*1',name:'own208-wg','listen-port':'55108',mtu:'1420','public-key':publicKey,'private-key':key,disabled:'false',running:'true'}]:[]))});await new Promise(r=>native.listen(0,'127.0.0.1',r)) +const report={date:'2026-10-13',block:'12.5.208',database,baseline,cases,scope:'Production compiled WireGuard GET/auth/mapper, own restricted PostgreSQL and local RouterOS HTTP fixture with nonempty synthetic private key; isolated signed JWT',passed:false} +try{await initDatabase();const port=native.address().port;await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own208-native','127.0.0.1',$1,false,true,'fixture','fixture')",[port]);app=await buildApp({logger:false,startScheduler:false});await app.ready() +for(const role of ['reader','writer','admin']){const permission='mm:network:'+({reader:'read',writer:'write',admin:'admin'}[role]);const token=app.jwt.sign({sub:'own208-'+role,apps:['mm'],permissions:[permission],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});for(const flag of ['', '?includePrivateKey=1','?includePrivateKey=true']){const before=calls,r=await app.inject({method:'GET',url:'/api/wireguard'+flag,headers:{authorization:'Bearer '+token}});const denied=!baseline&&role==='reader'&&flag!=='';assert.equal(r.statusCode,denied?403:200);if(denied){assert.equal(calls,before);assert(!r.body.includes(key))}else{const dto=r.json();assert.equal(dto.interfaces.length,1);assert.equal(dto.interfaces[0].publicKey,publicKey);assert.equal(dto.interfaces[0].privateKey,flag?key:undefined)}cases.push({role,flag,status:r.statusCode,privateKeyExposed:!denied&&!!flag,nativeRequestCount:calls-before})}} +const fakeAdmin=app.jwt.sign({sub:'own208-is-admin',apps:['mm'],permissions:['mm:network:read'],is_admin:true,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'});const before=calls,r=await app.inject({method:'GET',url:'/api/wireguard?includePrivateKey=1',headers:{authorization:'Bearer '+fakeAdmin}});assert.equal(r.statusCode,baseline?200:403);if(!baseline)assert.equal(calls,before);cases.push({name:'is_admin alone',status:r.statusCode,permissionRequired:true});assert.equal((await app.inject({method:'GET',url:'/api/wireguard?includePrivateKey=1'})).statusCode,401);cases.push({role:'unauthenticated',status:401});report.passed=true +}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/wireguard208-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/wireguard208-'+(baseline?'baseline':'fixed')+'-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))} +if(!report.passed)process.exitCode=1 diff --git a/backend/src/routes/wireguard.ts b/backend/src/routes/wireguard.ts index 0aceba99..d1e9615c 100644 --- a/backend/src/routes/wireguard.ts +++ b/backend/src/routes/wireguard.ts @@ -43,6 +43,7 @@ import { parseWireguardSnapshot, planWireguardRestore, planStaticInterfaceAddres import { executeRosOps } from "../services/ros-ops.js" import { parseDbServerId } from "../utils/server-id.js" import { z } from "zod" +import { hasPermission } from "../lib/permissions.js" function serverIdParam(v: string): string { return decodeURIComponent(v) @@ -126,6 +127,9 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/wireguard", async (req, reply) => { const q = req.query as { serverId?: string; includePrivateKey?: string } const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true" + if (includePrivateKey && req.authUser && !hasPermission(req.authUser.permissions, "mm:network:write")) { + return reply.status(403).send({ error: "Для чтения приватного ключа WireGuard нужны права на изменение сети" }) + } const result = await listWireGuardInterfaces({ serverId: q.serverId, includePrivateKey, diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index 08385aff..7c97906a 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -48,6 +48,7 @@ export interface WgIfaceWithServer extends WireGuardInterface { interface WireguardDataGridProps { interfaces: WgIfaceWithServer[] compactServer?: boolean + canWrite?: boolean emptyAction?: ReactNode onExport: (iface: WgIfaceWithServer) => void onAddPeer?: (iface: WgIfaceWithServer) => void @@ -58,14 +59,14 @@ interface WireguardDataGridProps { onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void } -type WireguardActions = Pick +type WireguardActions = Pick const WireguardActionsContext = createContext(null) // Preserve menu state while callbacks and the current row data update. function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) { const actions = useContext(WireguardActionsContext) if (!actions) throw new Error("WireGuard actions context is missing") - const { onExport, onAddPeer, onToggle, toggleBusy, onDelete } = actions + const { onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite = true } = actions const iface = row.original return (
- onExport(iface)}> + onExport(iface)}> Экспорт {onAddPeer && ( - onAddPeer(iface)}> + onAddPeer(iface)}> Добавить пира @@ -107,7 +108,7 @@ function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) {onToggle && ( <> - onToggle(iface)}> + onToggle(iface)}> {iface.enabled ? "Отключить" : "Включить"} @@ -116,7 +117,7 @@ function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) {onDelete && ( <> - onDelete(iface)}> + onDelete(iface)}> Удалить @@ -131,6 +132,7 @@ function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) function WireguardDataGrid({ interfaces, compactServer = false, + canWrite = true, emptyAction, onExport, onAddPeer, @@ -311,7 +313,7 @@ function WireguardDataGrid({ } return ( - + localStorage.setItem('mmapp_token',t),jwt) + await page.route('**/health',r=>r.fulfill({json:{ok:true}})) + await page.route('**/api/**',r=>{const path=new URL(r.request().url()).pathname;if(r.request().method()!=='GET'){writes++;return r.fulfill({status:403,json:{error:'Unexpected mutation in readonly workspace fixture'}})} + const iface={id:'1:*W',rosId:'*W',name:'own208-wg',serverId:'1',serverName:'Own208 router',listenPort:55208,mtu:1420,comment:'',enabled:true,status:'up',peers:[{id:'own208-peer',rosId:'*P',publicKey:'own208-public',allowedIps:['10.208.0.2/32'],disabled:false}]} + const tunnel={id:'own208-gre',serverId:'1',name:'own208-gre',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',localInnerIp:'10.208.0.1/30',remoteInnerIp:'10.208.0.2/30',poolId:'live',ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:'inherit',clampTcpMss:true,allowFastPath:true,comment:'',enabled:true,status:'up'} + const rule={id:'own208-rule',rosId:'*R',serverId:'1',serverName:'Own208 router',family:'ip',table:'filter',chain:'forward',action:'accept',proto:'tcp',src:'',dst:'',port:'443',iface:'',comment:'Own208 rule',enabled:true,hits:1} + const json=path==='/api/auth/config'?{required:true,portal_url:'http://own.portal.invalid'}:path==='/api/servers'?[{id:1,name:'Own208 router',host:'192.0.2.208',type:'exit-node',enabled:true,country:'RU',site:'OWN',asn:'',status:'online',latency:1}]:path==='/api/wireguard'?{interfaces:[iface]}:path==='/api/ipsec'?{servers:[],clients:[]}:path==='/api/gre/tunnels'||path==='/api/filters/gre-tunnels'?{tunnels:[tunnel]}:path==='/api/firewall/all'?{rules:[rule],addressLists:[]}:path==='/api/filters/rules'?{rulesets:[{serverId:'1',rules:[{id:'own208-filter',community:'65001:208',gateway:'',gatewayTunnelId:'',action:'blackhole',description:'Own208 filter'}]}],greTunnels:[],live:true}:path==='/api/recursive-routes'?{routes:[]}:path==='/api/evobgp/settings'?{enabled:false,baseUrl:'',secretConfigured:false}:{} + return r.fulfill({json})});return ()=>writes +} +for(const width of [1440,320])for(const section of sections)for(const writer of [false,true])test(`Native208 ${section} ${writer?'writer':'reader'} ${width}`,async({page})=>{ + await page.setViewportSize({width,height:1000});const writes=await setup(page,writer);await page.goto('/'+section+'?server=1');const create=page.getByRole('button',{name:names[section],exact:true}).first();await expect(create).toBeVisible();if(writer){await expect(create).toBeEnabled()}else{await expect(create).toBeDisabled();await expect(page.getByRole('status').filter({hasText:'Режим просмотра'})).toBeVisible();await create.evaluate(b=>(b as HTMLButtonElement).click())} + if(section==='wireguard'&&!writer){await expect(page.getByRole('button',{name:'Импорт',exact:true})).toBeDisabled();await page.getByRole('button',{name:'Действия: own208-wg',exact:true}).click();await expect(page.getByRole('menuitem',{name:'Экспорт',exact:true})).toBeDisabled();await expect(page.getByRole('menuitem',{name:'Добавить пира',exact:true})).toHaveCount(0);await page.keyboard.press('Escape')} + expect(writes()).toBe(0);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true) +})