正在连接本地服务…
功能列表暂时无法读取。环境信息仍可单独查看;账号和维护操作请先重新读取列表。
你准备怎样使用 Claude?
让 Claude 稳定可用
Claude 环境检查
检查当前浏览器的 HTTP 出口和 WebRTC 候选地址。
只读检测,探测会联系外部服务
检测会联系 Cloudflare、ipify、ipinfo、Claude / Anthropic 及 Google / Cloudflare STUN。服务可见探测来源;本机保存最近 50 次脱敏结果。
本次按什么目标检查
首次检测前,确认一次使用方式和连接方式。
地区与时区
首次检测前,确认一次使用方式和连接方式。
接下来,到自己的 Claude 试一条消息
全部检查
勾选要检查的项目,检测后预览并一键修改。红色项目需要手动处理,展开即可看到步骤。
尚未检测,下面是完整检查目录。
-节点地区与恢复记录
先在目标设置选择城市。只切换你已有的节点;组内其他应用的新连接也可能受影响。节点名称与真实出口分开核对。
未读取控制接口;没有兼容接口时,请在代理客户端手动选择节点。
节点地区与恢复记录
先在目标设置选择城市。只切换你已有的节点;组内其他应用的新连接也可能受影响。节点名称与真实出口分开核对。
未读取控制接口;没有兼容接口时,请在代理客户端手动选择节点。
工具
开源方
仅经指定代理检测 Claude 网页与 API,区分连接失败、需认证、拒绝访问、限流及服务错误。代理失败不回退直连。
环境信息
点击后在下方显示,不会打开额外窗口。设备和浏览器信息用于排障,不推断账号风险。
选择一项查看;无需先安装维护核心。
与 claude-switcher 有什么区别
EnvScope 原生提供逐项检查、配置预览与恢复、独立账号资料、加密存档、临时浏览器、托盘和完整软件更新。持续维护提供基线恢复、单独批准的 10 秒进程守护,以及可授权的 Mac 60 秒系统时区守护。
Clash Verge 置顶路由、Mac 系统时区服务和原版加密 DMG 兼容已原生实现。系统网络深度修复、其他客户端适配和旧组件迁移仍需维护核心或手动处理;具体能力与验收范围见环境全览。
上游核对至 223a5cc,已核对其新增 Windows 地区设置;随附源码仍固定在 870c4ca。完整升级尚未完成,当前缺口和验证要求持续记录。
完整功能升级核对表与 claude-switcher 有什么区别
EnvScope 原生提供逐项检查、配置预览与恢复、独立账号资料、加密存档、临时浏览器、托盘和完整软件更新。持续维护提供基线恢复、单独批准的 10 秒进程守护,以及可授权的 Mac 60 秒系统时区守护。
Clash Verge 置顶路由、Mac 系统时区服务和原版加密 DMG 兼容已原生实现。系统网络修复、客户端适配和旧组件迁移提供原生方案;不支持的操作会保留具体手动指引。
上游功能核对至 92cd62f;随附源码固定在 870c4ca。各平台实现方式、需手动处理的项目和验收范围见完整核对表。
完整功能升级核对表配置备份
最近 20 份可识别恢复记录,关闭后仍保留。撤销会先停用持续维护,并校验备份和当前文件;后续已有修改时拒绝覆盖。原文位于 ~/.envscope/config-backups,可能含密钥,不可分享。
临时浏览器
独立资料,不带入日常 Cookie 或扩展。关闭后清理,应用退出后由独立进程继续检查。
diff --git a/envscope/web/minimal.css b/envscope/web/minimal.css index e86bd7b..20bb4fb 100644 --- a/envscope/web/minimal.css +++ b/envscope/web/minimal.css @@ -2,15 +2,18 @@ [hidden]{display:none!important} :root{color-scheme:light;--paper:#fff;--rail:#f7f7f7;--ink:#202020;--muted:#646464;--line:#e6e6e6;--selected:#eaeaea;--rail-width:192px} body{margin:0;min-width:960px;background:var(--paper);color:var(--ink);font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei UI",sans-serif} -body::before{content:"";position:fixed;inset:0 auto 0 0;width:var(--rail-width);background:var(--rail);border-right:1px solid var(--line);z-index:-1} -header{position:fixed;left:0;top:0;width:var(--rail-width);padding:35px 24px 28px} +.app-header{max-width:1040px;margin:0 auto;padding:20px 40px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)} +header{display:flex;align-items:baseline;gap:14px} header a{color:var(--ink);text-decoration:none;font-size:20px;line-height:1.3;font-weight:650;letter-spacing:-.65px} -header span{display:block;margin-top:7px;font-size:12px;color:var(--muted)} -nav{position:fixed;left:14px;top:141px;width:calc(var(--rail-width) - 28px);display:flex;flex-direction:column;gap:5px} +header span{font-size:12px;color:var(--muted)} +nav{display:flex;align-items:center;gap:18px} +#more-menu{position:relative;margin:0;color:var(--ink);font-size:13px} +#more-menu>summary{padding:8px 0} +.more-menu-items{position:absolute;right:0;top:100%;z-index:10;background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:6px;min-width:190px;display:flex;flex-direction:column} .tab{border:0;border-radius:5px;text-align:left;padding:9px 14px;background:transparent;color:var(--muted);font-size:14px} .tab:hover:not(:disabled){background:#efefef} .tab[aria-current=page]{background:var(--selected);color:var(--ink);font-weight:600} -main{margin-left:var(--rail-width);max-width:1104px;padding:42px 40px 52px;min-height:100vh} +main{margin:0 auto;max-width:1040px;padding:28px 40px 40px;min-height:calc(100vh - 158px)} .page{max-width:100%} .connection-status{font-size:11px;color:var(--muted);margin:0 0 20px} .connection-banner{border:1px solid var(--ink);border-radius:5px;padding:18px 20px;margin-bottom:24px;max-width:80ch} @@ -40,12 +43,12 @@ button:disabled{opacity:.45;cursor:default} .actions span{font-size:12px;color:var(--muted)} .intro-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:30px} .intro-heading h1{margin:0} -.intro-heading .actions{margin:0;flex-direction:column;align-items:flex-end;gap:7px;min-height:67px} +.intro-heading .actions{margin:0;flex-direction:column;align-items:flex-end;gap:7px} #start{background:var(--ink);border-color:var(--ink);color:#fff;min-width:112px;padding:10px 20px;font-weight:550} #start:hover:not(:disabled){background:#444;border-color:#444} -#mode-note{margin:-15px 185px 0 0;color:var(--muted);font-size:13px;max-width:64ch} +#mode-note{margin:8px 0 0;color:var(--muted);font-size:13px;max-width:64ch} .note,.limits p{font-size:12px;line-height:1.8;color:var(--muted);max-width:80ch} -.introduction{margin-bottom:36px;padding-bottom:27px;border-bottom:1px solid var(--line)} +.introduction{margin-bottom:24px;padding-bottom:0} .network-note{margin-top:12px;max-width:78ch} .network-note p{margin:10px 0 0} .result-summary{display:flex;justify-content:space-between;gap:20px;align-items:baseline} @@ -88,7 +91,8 @@ dl div{display:flex;flex-direction:column;gap:5px;min-width:110px;font-size:12px dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere} .limits{margin-top:30px;padding-top:22px;border-top:1px solid var(--line)} .limits p{margin:10px 0 0} -footer{position:fixed;left:24px;bottom:27px;width:calc(var(--rail-width) - 48px);display:flex;flex-direction:column;gap:13px;font-size:11px;line-height:1.9;color:var(--muted)} +footer{max-width:960px;margin:0 auto;padding:18px 0;border-top:1px solid var(--line);display:flex;align-items:center;gap:24px;font-size:11px;line-height:1.9;color:var(--muted)} +footer span{margin-left:auto;text-align:right}footer br{display:none} footer .link{text-align:left;font-size:11px} .subtle{font-size:13px;color:var(--muted);margin-bottom:30px;max-width:72ch} .tool-section{border-top:1px solid var(--line);margin-top:30px;padding-top:22px} @@ -301,3 +305,60 @@ td .link{margin-left:15px} #checks-plan:empty, #checks-region-recovery:empty { display: none; } #checks-plan { padding: 18px 0; border-bottom: 1px solid #ddd; } #checks-region-options { margin: 18px 0; } + +/* A single desktop workflow; supporting tools stay in the More menu. */ +.connection-status{margin-bottom:16px} +.target-environment{margin-bottom:28px;padding:20px 0} +.target-heading h2{font-size:18px} +#target-form{max-width:none;margin-top:20px} +#target-form .target-primary-fields{gap:14px 24px} +#target-form details{margin:12px 0} +#target-matched{margin:10px 0} +.target-ip{display:flex;align-items:baseline;gap:16px} +.target-ip p{flex:1;margin-bottom:8px} +.target-ip button{font-size:12px} +#target-country-policy{margin-bottom:14px} +#target-form>.actions{margin:18px 0 0} +.flow-progress{display:none} +#flow-state{font-size:12px;margin:12px 0} +#issue-section{margin:24px 0 30px} +#issue-section>h2,.solution-tools>h2,.flow-upkeep h2{font-size:18px} +#issue-summary{margin:7px 0 14px} +.problem{padding:14px 0;margin:0;color:var(--ink)} +.problem>summary{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;width:100%;list-style:none} +.problem>summary::-webkit-details-marker{display:none} +.problem-title{min-width:0;flex:1} +.problem-title strong{display:block;font-size:14px;font-weight:600} +.problem-title .observed{display:block;color:var(--muted);font-size:12px;line-height:1.8;margin:5px 0 0;max-width:82ch;overflow-wrap:anywhere} +.problem-badge{font-size:12px;white-space:nowrap;text-decoration:underline;text-underline-offset:4px;padding-top:1px} +.problem[data-manual=true] .problem-badge{color:#a92828} +.problem[open]{padding-bottom:20px} +.problem[open]>.cause{margin:12px 0;color:var(--muted)} +.problem-actions button{font-size:12px} +.solution-tools{padding-top:22px;margin-top:22px} +.solution-tools[data-workflow=true]>.actions:not(#solution-backups){display:none} +#solution-plan h3{margin:15px 0 9px;font-size:14px} +#solution-plan>ul{padding-left:20px;font-size:13px} +#solution-output:empty,#flow-upkeep-output:empty,#solution-backups:empty{display:none} +.flow-upkeep{margin-top:32px;padding-top:24px} +.flow-upkeep>p{margin:8px 0 14px} +#flow-watchdog-options{margin:14px 0} +#flow-watchdog-options>summary{font-size:13px;color:var(--ink)} +#flow-watchdog-options .checkbox{display:inline-flex;width:auto;margin:8px 24px 5px 0;font-size:13px} +#flow-watchdog-options>label:not(.checkbox){max-width:520px} +.learning-inline{max-width:none;border-left:2px solid var(--line);padding:6px 16px;margin-bottom:24px} +.learning-inline .learning-top{margin:0;float:right;gap:18px} +.learning-inline h1{font-size:14px;letter-spacing:0;margin:0 0 6px} +.learning-inline #learning-body p{font-size:12px;color:var(--muted);max-width:84ch;margin:0} +.learning-inline .actions,.learning-inline .learning-footnote,.learning-inline #learning-progress{display:none} +.learning-inline #learning-skip{font-size:12px} +.inline-upkeep-options .checkbox{margin:0;font-size:13px} +.inline-upkeep-options{gap:14px 24px;margin-top:16px} +.inline-upkeep-options>label:first-child{width:145px} +.plan-notes{margin:14px 0 0}.plan-notes p{margin:10px 0;font-size:12px;line-height:1.8;max-width:82ch} +.result-summary #show-all-checks{margin:0;font-size:12px} +#issue-section{margin-top:14px} +#more-results{border-top:1px solid var(--line);margin-top:28px;padding-top:16px} +#more-results>summary{color:var(--muted);font-size:12px} +#more-results[open]>.try-claude{border-top:0;margin-top:10px} +@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} diff --git a/envscope/web/region_choices.mjs b/envscope/web/region_choices.mjs index da344d3..31f588f 100644 --- a/envscope/web/region_choices.mjs +++ b/envscope/web/region_choices.mjs @@ -3,6 +3,6 @@ export const countryCode=value=>/^[A-Za-z]{2}$/.test(String(value||'').trim())?S export function cityChoices(profiles,country){return profiles.filter(p=>p.country===countryCode(country));} export function suggestedCity(profiles,observation){ if(observation?.state!=='observed'||!observation.timezone)return null; - const matches=cityChoices(profiles,observation.country).filter(p=>p.iana===observation.timezone); + const matches=cityChoices(profiles,observation.country).filter(p=>p.iana===observation.timezone||p.aliases?.includes(observation.timezone)); return matches.length===1?matches[0]:null; } diff --git a/envscope/web/target.mjs b/envscope/web/target.mjs index ee05c00..bd7d3c9 100644 --- a/envscope/web/target.mjs +++ b/envscope/web/target.mjs @@ -3,9 +3,24 @@ const $=id=>document.getElementById(id); export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ let settings=null,loading=false,saving=false,pendingUsage=null,profiles=[]; - let lookupBusy=false,lookupTimer=null,lookupRevision=0,editRevision=0; + let lookupBusy=false,lookupTimer=null,lookupRevision=0,editRevision=0,manualTarget=false; const form=$('target-form'); const field=name=>form.elements.namedItem(name); + function describeTarget(){ + $('target-matched').textContent=field('locale').value?`地区格式 ${field('locale').value}${field('language').value?',应用语言目标 '+field('language').value:''}。可在下方展开调整。`:'选择城市后自动匹配时区与地区格式。'; + $('target-connection-label').textContent=`${field('usage').selectedOptions[0]?.textContent||'使用方式'},${field('connection_mode').selectedOptions[0]?.textContent||'连接方式'} · 调整连接选项`; + const unsupported=['CN','RU','HK','MO'].includes(countryCode(field('usage_country').value)); + $('target-country-policy').textContent=unsupported?'实际地区未列入 Claude 官方支持名单;改变节点或时区不能使地区条件通过。':'实际使用地区填写所在地,节点地区按出口 IP 建议,可手动调整。'; + $('target-country-policy').classList.toggle('inspection-error',unsupported); + } + function countryOptions(countries){ + if(!countries?.length)return; + const before=field('country').value; + field('country').replaceChildren(new Option('选择节点 / 期望配置地区',''),...countries.map(c=>new Option(c.label+' ('+c.code+')',c.code))); + if(before&&!countries.some(c=>c.code===before))field('country').add(new Option(before,before)); + field('country').value=before; + $('region-codes').replaceChildren(...countries.map(c=>{const option=new Option(c.code,c.code);option.label=c.label;return option;})); + } function showWireguard(value={},saved=field('wireguard_service').value){ const select=field('wireguard_service'); const rows=Array.isArray(value.services)?value.services:[]; @@ -27,6 +42,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ field('region_profile').value=selected.id; for(const k of ['country','timezone','locale'])field(k).value=selected[k]; field('language').value=['desktop','both'].includes(field('usage').value)?selected.language:''; + describeTarget(); } function filterCities(country,{clear=false}={}){ const previous=field('region_profile').value,choices=cityChoices(profiles,country); @@ -34,7 +50,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ const preserved=choices.find(p=>p.id===previous); if(preserved&&!clear)field('region_profile').value=previous; else if(previous||clear)for(const k of ['timezone','locale','language'])field(k).value=''; - return choices; + describeTarget();return choices; } function controls(){ $('target-confirm').disabled=loading||saving||lookupBusy||!!lookupTimer; @@ -55,19 +71,20 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ try{ const value=await run('environment-location',params); if(revision!==lookupRevision||form.hidden)return; - if(edited!==editRevision){$('target-ip-status').textContent='读取期间你修改了目标,已保留手动选择。需要时可重新读取 IP。';return;} + if(edited!==editRevision||manualTarget){$('target-ip-status').textContent=`${value.country?'本次出口 '+value.country+'。':''}已保留手动选择或已保存目标。点“重新读取 IP”可重新匹配。`;return;} const country=countryCode(value.country),actual=countryCode(field('usage_country').value); if(country){ + if(![...field('country').options].some(o=>o.value===country))field('country').add(new Option(country,country)); const changed=field('country').value!==country; field('country').value=country;const choices=filterCities(country,{clear:changed}); const recommended=suggestedCity(profiles,value); if(!field('region_profile').value&&recommended)fillCity(recommended); const mismatch=actual!==country?' 与填写的实际地区不同,请核对;实际地区未被更改。':''; - $('target-ip-status').textContent=`出口 IP:${value.ip||'多个地址'} · ${country}。${value.reason||''} `+(choices.length?`仅显示 ${country} 的 ${choices.length} 个城市。`:'此国家暂无城市预设,请自定义目标。')+(recommended?` 时区对应建议:${recommended.label}。`:' 请选择城市后匹配时区与语言。')+mismatch; + $('target-ip-status').textContent=`出口 IP:${value.ip||'多个地址'} · ${country}。`+(choices.length?`仅显示 ${country} 的 ${choices.length} 个城市 / 时区。`:'此地区暂无映射,可手动填写时区与地区格式。')+(recommended?` 建议:${recommended.label}。`:value.reason||'请选择城市后匹配时区与语言。')+mismatch; }else{ const fallback=countryCode(field('country').value)||actual; filterCities(fallback); - $('target-ip-status').textContent=(value.reason||'IP 未能读取。')+` 当前按手动目标或实际地区 ${fallback} 筛选,尚未根据 IP 匹配;可在“其他目标”修改出口国家。`; + $('target-ip-status').textContent=(value.reason||'IP 未能读取。')+` 当前按 ${fallback} 筛选,尚未根据 IP 匹配;可直接选择节点地区。`; } }catch(error){ if(revision===lookupRevision&&!form.hidden)$('target-ip-status').textContent='IP 读取未完成:'+error.message+'。可手动选择城市,或重新读取。'; @@ -80,11 +97,13 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ $('target-code-fields').hidden=!code; $('target-proxy-field').hidden=field('connection_mode').value!=='http_proxy'; $('target-language-field').hidden=!['desktop','both'].includes(field('usage').value); + describeTarget(); if($('target-language-field').hidden)field('language').value=''; if(!code){field('provider').value='anthropic';field('auth_mode').value='existing';} $('target-wireguard').hidden=field('client').value!=='wireguard'; } function populate(value){ + if(value.country&&![...field('country').options].some(o=>o.value===value.country))field('country').add(new Option(value.country,value.country)); for(const node of form.elements){if(!node.name||!(node.name in value))continue;node.value=Array.isArray(value[node.name])?value[node.name].join('\n'):value[node.name]??'';} if(!field('usage').value)field('usage').value='desktop'; if(!field('connection_mode').value)field('connection_mode').value='direct'; @@ -93,9 +112,9 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ function sync(value){ settings=value; $('target-summary').textContent=value?.environment_confirmed? - `${({desktop:'桌面版',cli:'Code',both:'桌面版与 Code',web:'网页版'})[value.usage]} · ${({direct:'直连',http_proxy:'HTTP / 系统代理',tun:'TUN / VPN'})[value.connection_mode]} · 实际地区 ${value.usage_country||'待确认'} · ${value.timezone||'不比较时区'}`: + `${({desktop:'桌面版',cli:'Code',both:'桌面版与 Code',web:'网页版'})[value.usage]} · ${({direct:'直连',http_proxy:'HTTP / 系统代理',tun:'TUN / VPN'})[value.connection_mode]} · 实际地区 ${value.usage_country||'待确认'} · 节点目标 ${value.country||'仅观察'} · ${value.timezone||'不比较时区'}`: '尚未确认目标。先确认用途与连接方式,再进行有针对性的检查。'; - $('target-edit').textContent=value?.environment_confirmed?'调整目标':'设置目标'; + $('target-edit').textContent=value?.environment_confirmed?'调整地区与时区':'设置地区与时区'; if(value?.environment_confirmed){ $('usage').value=value.usage; $('enhance-cli').checked=['cli','both'].includes(value.usage); @@ -106,7 +125,8 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ async function open(){ if(loading||saving||isBusy())return; if(!await sessionPromise)return; - form.hidden=false;populate(settings||{});$('target-output').textContent=''; + form.hidden=false;manualTarget=!!settings?.environment_confirmed;populate(settings||{});$('target-output').textContent=''; + $('start').hidden=true;$('audit-results').hidden=true; document.dispatchEvent(new CustomEvent('envscope-target-editing')); $('target-cancel').hidden=!settings?.environment_confirmed; loading=true;$('target-confirm').disabled=true; @@ -114,6 +134,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ try{ const value=await run('environment-suggest'); profiles=value.profiles||[]; + countryOptions(value.countries); $('target-region-profile').replaceChildren(new Option('自定义 / 仅观察',''),...profiles.map(p=>new Option(p.label,p.id))); populate(value.proposed||{}); showWireguard(value.wireguard||{},value.proposed?.wireguard_service||''); @@ -124,10 +145,10 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ }catch(error){$('target-observed').textContent='当前设置未能完整读取,可手动填写目标。'+error.message;} finally{loading=false;controls();} if(countryCode(field('usage_country').value))queueLookup(0); - if(!form.hidden&&!$('audit-content').hidden){form.scrollIntoView({block:'nearest'});field('usage').focus();} + if(!form.hidden&&!$('audit-content').hidden){form.scrollIntoView({block:'nearest'});field('usage_country').focus();} } field('usage').addEventListener('change',()=>{visibility();const p=profiles.find(p=>p.id===field('region_profile').value);if(p)fillCity(p);editRevision++;}); - field('connection_mode').addEventListener('change',()=>{visibility();queueLookup();}); + field('connection_mode').addEventListener('change',()=>{manualTarget=false;visibility();queueLookup();}); field('client').addEventListener('change',()=>{ if(field('client').value==='wireguard')field('connection_mode').value='tun'; else field('wireguard_service').value=''; @@ -135,24 +156,26 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ }); $('target-wireguard-read').addEventListener('click',()=>void readWireguard()); field('usage_country').addEventListener('input',()=>{ + manualTarget=false; + describeTarget(); if(!lookupBusy)filterCities(countryCode(field('country').value)||countryCode(field('usage_country').value)); queueLookup(); }); field('target_proxy').addEventListener('input',()=>queueLookup(500)); - $('target-read-ip').addEventListener('click',()=>queueLookup(0)); + $('target-read-ip').addEventListener('click',()=>{manualTarget=false;queueLookup(0);}); field('region_profile').addEventListener('change',()=>{ const selected=profiles.find(p=>p.id===field('region_profile').value); - editRevision++;fillCity(selected); + editRevision++;manualTarget=true;fillCity(selected); $('target-output').textContent=selected?'已填入 '+selected.label+' 的目标值。确认后先检测,修改系统需再审核方案。':'自定义目标;留空的项目只记录。'; }); - for(const k of ['timezone','locale','language'])field(k).addEventListener('input',()=>{editRevision++;if(k!=='language')field('region_profile').value='';}); - field('country').addEventListener('input',()=>{editRevision++;filterCities(field('country').value,{clear:true});$('target-ip-status').textContent='正在使用手动出口国家;选择城市后匹配时区和语言。';}); + for(const k of ['timezone','locale','language'])field(k).addEventListener('input',()=>{editRevision++;manualTarget=true;if(k!=='language')field('region_profile').value='';describeTarget();}); + field('country').addEventListener('change',()=>{editRevision++;manualTarget=true;const choices=filterCities(field('country').value,{clear:true});if(choices.length===1)fillCity(choices[0]);$('target-ip-status').textContent='正在使用手动节点地区;选择城市后匹配时区和语言。';}); $('target-configure').addEventListener('click',()=>{ if(!settings?.environment_confirmed||!form.hidden){void open();$('target-output').textContent='先选择期望地区与城市,确认目标并检测。';return;} document.dispatchEvent(new CustomEvent('envscope-region-configure')); }); $('target-edit').addEventListener('click',()=>void open()); - $('target-cancel').addEventListener('click',()=>{if(!saving&&!loading){form.hidden=true;lookupRevision++;clearTimeout(lookupTimer);lookupTimer=null;controls();$('target-edit').focus();}}); + $('target-cancel').addEventListener('click',()=>{if(!saving&&!loading){form.hidden=true;$('start').hidden=false;$('audit-results').hidden=$('workspace').dataset.state!=='complete';lookupRevision++;clearTimeout(lookupTimer);lookupTimer=null;controls();$('target-edit').focus();}}); form.addEventListener('submit',async event=>{ event.preventDefault();if(loading||saving||lookupBusy||lookupTimer||isBusy())return; saving=true;$('target-confirm').disabled=true;$('start').disabled=true; @@ -164,7 +187,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ if(params.connection_mode!=='http_proxy')params.target_proxy=''; if(params.client!=='wireguard')params.wireguard_service=''; const value=await run('environment-confirm',params); - sync(value.settings);form.hidden=true;completed=true; + sync(value.settings);form.hidden=true;$('start').hidden=false;completed=true; document.dispatchEvent(new CustomEvent('envscope-target-changed',{detail:{settings:value.settings}})); }catch(error){$('target-output').textContent='目标未保存:'+error.message;} finally{saving=false;$('target-confirm').disabled=false;if(!completed)$('start').disabled=false;document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:false}}));} @@ -178,7 +201,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ if(!await sessionPromise)return true; if(loading||saving||lookupBusy||lookupTimer)return false; if(settings?.environment_confirmed&&form.hidden)return true; - if(form.hidden)await open();else{form.scrollIntoView({block:'nearest'});field('usage').focus();} + if(form.hidden)await open();else{form.scrollIntoView({block:'nearest'});field('usage_country').focus();} return false; }}; } diff --git a/envscope/web/workflow.mjs b/envscope/web/workflow.mjs new file mode 100644 index 0000000..dd81904 --- /dev/null +++ b/envscope/web/workflow.mjs @@ -0,0 +1,42 @@ +// Summarize actual failures and missing required evidence without treating either as a pass. +export function workflowIssues(report){ + const findings=new Map((report.findings||[]).map(f=>[f.id,f])); + const annotations=new Map((report.assessment?.items||[]).map(i=>[i.id,i])); + const options=report.repair_options||{}; + const problems=(report.diagnosis?.problems||[]).map(p=>{ + const checks=Object.entries(options).filter(([,v])=>v.problem_ids?.includes(p.id)).map(([id])=>id); + const check=checks.find(id=>annotations.get(id)?.required)||checks[0]||p.id; + const annotation=annotations.get(check),automatic=!!p.repair||checks.some(id=>id!=='baseline.country'&&['region','solution'].includes(options[id]?.action)); + return {...p,check,automatic,required:!!annotation?.required,state:annotation?.state||(p.certainty==='confirmed'?'problem':'review')}; + }); + const known=new Set(problems.map(p=>p.id)); + for(const item of report.assessment?.items||[]){ + if(!item.required||['passed','not_applicable','informational'].includes(item.state)||known.has(item.id))continue; + const mapped=options[item.id]?.problem_ids||[]; + if(mapped.some(id=>known.has(id)))continue; + const finding=findings.get(item.id),remedy=options[item.id]||{}; + problems.push({id:item.id,check:item.id,title:finding?.title||item.title||item.id, + observed:finding?.summary||item.reason||'本轮未取得必需证据',cause:item.reason, + steps:remedy.steps||[item.next_step||'补全本项证据后重新检测。'], + action:item.action==='permission-help'?'permission-help':'retest', + automatic:item.id!=='baseline.country'&&['region','solution'].includes(remedy.action),required:true,state:item.state}); + known.add(item.id); + } + return problems.sort((a,b)=>Number(b.required)-Number(a.required)||(a.priority??2)-(b.priority??2)); +} + +export function workflowChecks(report,enhanceCli=false){ + const findings=new Map((report.findings||[]).map(f=>[f.id,f])); + const annotations=new Map((report.assessment?.items||[]).map(i=>[i.id,i])); + const problems=new Set((report.diagnosis?.problems||[]).map(p=>p.id)); + // The report carries the server-validated selection; never infer permission from UI labels. + return (report.selection?.selected||[]).filter(id=>{ + const finding=findings.get(id),annotation=annotations.get(id),remedy=report.repair_options?.[id]; + if(enhanceCli&&id==='extra.cli_env'&&finding&&finding.scope!=='not-selected'&& + !['not_applicable','unsupported'].includes(annotation?.state)&& + (['cli','both'].includes(report.usage)||report.usage==='auto'&&finding.evidence?.some(e=>e.installation_count||e.config?.exists)))return true; + return finding&&finding.scope!=='not-selected'&&remedy?.needs_attention&& + !['not_applicable','informational','unsupported'].includes(annotation?.state)&& + (annotation?.required||finding.status==='fail'||remedy.problem_ids?.some(p=>problems.has(p))); + }); +} diff --git a/licenses/Unicode-3.0.txt b/licenses/Unicode-3.0.txt new file mode 100644 index 0000000..861b74f --- /dev/null +++ b/licenses/Unicode-3.0.txt @@ -0,0 +1,41 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2004-2025 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 diff --git a/pyproject.toml b/pyproject.toml index 7462cb2..bb1c667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "envscope" -version = "0.23.0" +version = "0.24.0" description = "Minimal desktop environment checks, guarded accounts and practical maintenance tools" readme = "README.md" requires-python = ">=3.10" license = "MIT" -license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "envscope/vendor/claude_switcher/LICENSE"] +license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "envscope/vendor/claude_switcher/LICENSE", "licenses/Unicode-3.0.txt"] authors = [{name = "EnvScope contributors"}] dependencies = ["cryptography>=46,<51", "psutil>=7.2,<8", "ruamel.yaml>=0.19.1,<0.20"] classifiers = [ diff --git a/scripts/account_recovery_smoke.py b/scripts/account_recovery_smoke.py index ba8972f..a52c047 100644 --- a/scripts/account_recovery_smoke.py +++ b/scripts/account_recovery_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.accounts import AccountManager, _manifest from envscope.operations import Operations @@ -62,7 +63,7 @@ def interrupt(path, text): try: page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') if page.locator('#learning-skip').is_visible(): page.locator('#learning-skip').click() - page.locator('.tab[data-page=accounts]').click() + open_page(page, 'accounts') expect(page.locator('#account-recovery')).to_be_visible() expect(page.locator('[data-account=B] button').first).to_be_disabled() before = _manifest(state), _manifest(data) diff --git a/scripts/browser_recovery_smoke.py b/scripts/browser_recovery_smoke.py index cf217cc..b2c1d1c 100644 --- a/scripts/browser_recovery_smoke.py +++ b/scripts/browser_recovery_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.browser_sessions import MARKER @@ -50,7 +51,7 @@ def main(): try: page.goto(f'http://127.0.0.1:{server.server_port}',wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() - page.locator('[data-page=tools]').click() + open_page(page, 'tools') expect(page.locator('#browser-session-status')).to_contain_text('旧版资料 1 个') expect(page.locator('#browser-session-status')).to_contain_text('无法核实 1 个') page.once('dialog',lambda dialog:dialog.dismiss()) diff --git a/scripts/browser_smoke.py b/scripts/browser_smoke.py index 6a61afb..d1e1b98 100644 --- a/scripts/browser_smoke.py +++ b/scripts/browser_smoke.py @@ -4,6 +4,7 @@ import threading import tempfile +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.service import make_server from envscope.model import Finding, Status @@ -116,6 +117,8 @@ def never_run(*args, **kwargs): page.route("https://**/*", lambda route: route.abort()) page.goto(f"http://127.0.0.1:{server.server_port}", wait_until="networkidle") expect(page.locator('#learning-panel')).to_be_visible() + expect(page.locator('#audit-content')).to_be_visible() + page.locator('#learning-replay').click() expect(page.locator('#audit-content')).to_be_hidden() expect(page.locator('#learning-title')).to_have_text('你准备怎样使用 Claude?') before_learning_settings=store.settings() @@ -149,7 +152,7 @@ def never_run(*args, **kwargs): assert '查看详情' not in page.locator('#audit-results').inner_text() assert 'PRIVATE_FIXTURE' not in page.locator('body').inner_text() assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), "horizontal overflow" - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() with page.expect_download() as downloaded: page.get_by_role("button", name="导出结果").click() download = downloaded.value @@ -160,17 +163,17 @@ def never_run(*args, **kwargs): assert value["mode"] == "one-click-desktop" assert 'PRIVATE_FIXTURE' not in json.dumps(value) page.evaluate('window.scrollTo(0,0)') - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() page.evaluate('window.scrollTo(0,0)') page.screenshot(path=str(ARTIFACTS/f'{label}-diagnosis.png')) - page.locator('#beginner-checks>summary').click() + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() page.locator('[data-claude-check="dns"]>summary').click() expect(page.locator('[data-claude-check="dns"]')).to_contain_text('电脑能不能找到 Claude 的服务器') expect(page.locator('[data-claude-check="dns"]')).to_contain_text('能力有限,不等于发现泄漏') expect(page.locator('[data-claude-check="dns"]')).to_contain_text('怎样长期保持') page.locator('#claude-checklist').screenshot(path=str(ARTIFACTS/f'{label}-checklist.png')) - page.locator('#beginner-checks>summary').click() - expect(page.locator('#solution-plan')).to_contain_text('确认后完成这些设置和修复') + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() + expect(page.locator('#solution-plan')).to_contain_text('将进行以下改动') assert config_file.read_bytes()==original, 'preview must not edit' page.screenshot(path=str(ARTIFACTS/f'{label}-plan.png')) page.locator('#apply-solution').click() @@ -198,7 +201,7 @@ def never_run(*args, **kwargs): expect(page.locator('#share')).to_be_enabled() expect(page.locator('#start')).to_be_enabled() # Native handoff always rechecks, and uses fixture callbacks only. - expect(page.locator('#desktop-handoff')).to_be_visible() + open_details(page, '#more-results'); expect(page.locator('#desktop-handoff')).to_be_visible() page.once('dialog',lambda dialog:dialog.dismiss()) page.locator('#desktop-launch').click() assert handoff_events == [], 'cancelled handoff cannot launch' @@ -231,14 +234,14 @@ def never_run(*args, **kwargs): page.locator('#start').click() expect(page.locator('#share')).to_be_enabled() expect(page.locator('#apply-solution')).to_have_count(0) - page.locator('#beginner-checks>summary').click() + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() page.locator('[data-claude-check="guard"]>summary').click() expect(page.locator('[data-claude-check="guard"]')).to_contain_text('不要求 TUN') expect(page.locator('[data-claude-check="guard"]').get_by_role('button',name='审核本项处理方案',exact=True)).to_have_count(0) - page.get_by_role('button',name='工具',exact=True).click() + open_page(page, 'tools') expect(page.locator('#config-backups')).to_contain_text('Claude 桌面配置') assert 'PRIVATE_FIXTURE' not in page.locator('#config-backups').inner_text() - page.get_by_role('button',name='持续维护',exact=True).click() + open_page(page, 'upkeep') expect(page.locator('#upkeep-state')).to_contain_text('尚未启用') page.locator('#upkeep-restore').check() page.locator('#upkeep-prepare').click() @@ -261,12 +264,12 @@ def never_run(*args, **kwargs): expect(page.locator('#session-state')).to_contain_text('后台原生巡检') assert page.locator('#claude-check-rows [data-claude-check]').count()==14 expect(page.locator('#try-feedback')).to_have_text('尚未由你确认实际回复。') - page.get_by_role('button',name='持续维护',exact=True).click() + open_page(page, 'upkeep') page.get_by_role('button',name='停用并撤销 Claude Code 用户配置',exact=True).first.click() expect(page.locator('#upkeep-state')).to_contain_text('尚未启用') expect(page.locator('#upkeep-output')).to_contain_text('已恢复原配置') assert json.loads(cli_file.read_text(encoding='utf-8'))['env']['HTTPS_PROXY'].endswith(':9999') - page.get_by_role("button", name="账号", exact=True).click() + open_page(page, 'accounts') expect(page.locator("#account-list")).to_contain_text("A") assert page.locator("#account-list tr").count() >= 5 page.locator("#new-slot").fill("work") @@ -275,7 +278,7 @@ def never_run(*args, **kwargs): expect(page.locator("#account-list")).to_contain_text("工作账号") expect(page.locator("#account-output")).to_contain_text("账号名称已保存") page.screenshot(path=str(ARTIFACTS / f"{label}-accounts.png"), full_page=True) - page.get_by_role("button", name="工具", exact=True).click() + open_page(page, 'tools') expect(page.locator('#opensource-options')).to_contain_text('Mihomo') expect(page.locator('#opensource-options')).to_contain_text('Clash Verge Rev') page.locator("#proxy-url").fill("http://127.0.0.1:7897") @@ -284,28 +287,28 @@ def never_run(*args, **kwargs): page.locator("#maintenance-action").select_option("snapshot-export") expect(page.locator("#maintenance-run")).to_be_disabled() page.screenshot(path=str(ARTIFACTS / f"{label}-tools.png"), full_page=True) - page.get_by_role("button", name="设置", exact=True).click() + open_page(page, 'settings') page.locator("#settings-form input[name=country]").fill("US") page.get_by_role("button", name="保存", exact=True).click() expect(page.locator("#settings-output")).to_contain_text("已保存") page.screenshot(path=str(ARTIFACTS / f"{label}-settings.png"), full_page=True) - page.get_by_role("button", name="检测", exact=True).click() + open_page(page, 'audit') page.locator('#start').click() expect(page.locator('#target-form')).to_be_visible() - page.locator('#target-form details>summary').click() - page.locator('#target-form [name=country]').fill('') + open_details(page, '#target-expert-options') + page.locator('#target-form [name=country]').select_option('') page.locator('#target-confirm').click() expect(page.locator("#share")).to_be_enabled() expect(page.locator('html')).to_have_attribute('data-operation-busy','false') expect(page.locator('#start')).to_be_enabled() - page.get_by_role("button", name="历史", exact=True).click() + open_page(page, 'history') page.get_by_role("button", name="比较", exact=True).click() expect(page.locator("#history-output")).to_contain_text("出口证据不足") page.screenshot(path=str(ARTIFACTS / f"{label}-history.png"), full_page=True) - for name in ("检测", "持续维护", "账号", "工具", "历史", "设置"): - page.get_by_role("button", name=name, exact=True).click() + for name in ('audit', 'upkeep', 'accounts', 'tools', 'history', 'settings'): + open_page(page, name) assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), name + " overflow" - page.get_by_role('button',name='检测',exact=True).click() + open_page(page, 'audit') page.route('**/api/audit',lambda route:route.fulfill(status=503,content_type='application/json',body='{"error":"offline fixture failure"}')) page.locator('#start').click() expect(page.locator('#start')).to_be_enabled() @@ -321,7 +324,7 @@ def never_run(*args, **kwargs): cli_file.write_bytes(code_original) page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') page.locator('#target-confirm').click() expect(page.locator('#apply-solution')).to_be_visible() expect(page.locator('#solution-plan')).to_contain_text('恢复 Node TLS 证书校验') @@ -331,7 +334,7 @@ def never_run(*args, **kwargs): page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() assert cli_file.read_bytes()==code_original, 'editing options invalidates pending confirmation' - page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') page.locator('#target-confirm').click() expect(page.locator('#apply-solution')).to_be_visible() page.locator('#beginner-checks').evaluate('(node)=>node.open=false') @@ -347,7 +350,7 @@ def never_run(*args, **kwargs): assert configured['mcpServers']=={'fixture':{'command':'PRIVATE_MCP'}} assert 'PRIVATE_SETTING' not in page.locator('body').inner_text() assert page.locator('.tab[data-page="audit"]').get_attribute('aria-current')=='page' - page.locator('#flow-upkeep-restore').check() + open_details(page, '#flow-watchdog-options'); page.locator('#flow-upkeep-restore').check() page.locator('#flow-upkeep-prepare').click() expect(page.locator('#flow-upkeep-plan')).to_contain_text('确认启用长期保持') assert not operations.maintenance._config().get('enabled') @@ -358,7 +361,7 @@ def never_run(*args, **kwargs): page.locator('#flow-upkeep-stop').click() expect(page.locator('#flow-upkeep-status')).to_contain_text('尚未启用') assert not operations.maintenance._config().get('enabled') - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() with page.expect_download() as configured_download: page.locator('#share').click() safe=Path(configured_download.value.path()).read_text(encoding='utf-8') diff --git a/scripts/check_preferences_smoke.py b/scripts/check_preferences_smoke.py index 020711c..183fb05 100644 --- a/scripts/check_preferences_smoke.py +++ b/scripts/check_preferences_smoke.py @@ -5,6 +5,7 @@ import threading from zipfile import ZipFile +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.client_repair import ClientRepair from envscope.guide import LESSON_VERSION @@ -52,7 +53,7 @@ def external(route): page.route('https://**/*',external) try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') page.locator('[name=optional][value=webrtc]').uncheck() page.locator('[name=optional][value=ecosystem]').uncheck() page.locator('#optional-failures').check() @@ -62,7 +63,7 @@ def external(route): page.wait_for_function("document.querySelector('#workspace').dataset.state==='complete'") assert calls and all('extra.ecosystem' not in selected and 'udp.stun' not in selected for selected in calls) assert page.evaluate('window.fixtureICE')==0 - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('本次检测') rows={f['id']:f for f in server.last_raw_report['findings']} assert rows['extra.ecosystem']['status']=='skipped' and rows['udp.stun']['status']=='skipped' @@ -85,22 +86,22 @@ def external(route): expect(page.locator('#diagnostic-log-state')).to_contain_text('已关闭') before=len(calls) page.reload(wait_until='networkidle') - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') expect(page.locator('[name=optional][value=webrtc]')).not_to_be_checked() expect(page.locator('[name=optional][value=ecosystem]')).not_to_be_checked() expect(page.locator('#optional-failures')).to_be_checked() expect(page.locator('#diagnostic-logging')).not_to_be_checked() assert len(calls)==before # A fresh audit after reload; old evidence is not used for a client action. - page.locator('.tab[data-page=audit]').click();page.locator('#start').click() + open_page(page, 'audit');page.locator('#start').click() expect(page.locator('#start')).to_be_enabled(timeout=45000) - page.locator('.tab[data-page=upkeep]').click();page.locator('#client-load').click() + open_page(page, 'upkeep');page.locator('#client-load').click() expect(page.locator('#client-app')).to_contain_text('FlClash') expect(page.locator('#client-tun')).to_be_disabled() expect(page.locator('#client-policy')).to_have_class('note manual-guidance') page.screenshot(path=str(output/f'{system}-{width}-client.png'),full_page=True) assert io.preference.read_bytes()==original and not io.calls - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') page.screenshot(path=str(output/f'{system}-{width}-preferences.png'),full_page=True) assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') assert not errors,errors diff --git a/scripts/client_repair_smoke.py b/scripts/client_repair_smoke.py index 9abcbfa..6c518e7 100644 --- a/scripts/client_repair_smoke.py +++ b/scripts/client_repair_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.client_repair import ClientRepair from envscope.client_config import tun_model @@ -47,7 +48,7 @@ def audit(*args,**kwargs): page.add_init_script('window.RTCPeerConnection=undefined;') page.route('https://**/*',lambda request:request.abort()) def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) - def upkeep():idle();page.locator('.tab[data-page=upkeep]').click() + def upkeep():idle();open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() diff --git a/scripts/components_smoke.py b/scripts/components_smoke.py index ea5e189..0e14f56 100644 --- a/scripts/components_smoke.py +++ b/scripts/components_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.legacy_components import ComponentMigration from envscope.operations import Operations @@ -71,7 +72,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) page.locator('#apply-solution').click() expect(page.locator('#solution-plan')).to_contain_text('注册独立的当前用户进程守护',timeout=30000);idle() assert jobs.calls==['retire'] and scheduler.calls==[] and len(audits)>=2 - page.locator('.tab[data-page=upkeep]').click();idle() + open_page(page, 'upkeep');idle() expect(page.locator('#components-state')).to_contain_text('旧任务已移除') page.locator('#components-restore').click();expect(page.locator('#components-plan')).to_contain_text('RunAtLoad') page.locator('#components-plan button').click() @@ -79,7 +80,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) assert jobs.calls==['retire','restore'] and scheduler.calls==[] and not errors proof.append({'system':system,'width':width,'one_stop_stages':True,'new_guard_needs_new_approval':True,'restore':True,'real_jobs_changed':False}) continue - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#components-state')).to_have_text('尚未迁移');idle() expect(page.locator('#components-all')).to_be_checked() page.locator('#components-all').uncheck();expect(page.locator('#components-prepare')).to_be_disabled() diff --git a/scripts/coverage_smoke.py b/scripts/coverage_smoke.py index 3304823..f9b36cd 100644 --- a/scripts/coverage_smoke.py +++ b/scripts/coverage_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading from unittest.mock import Mock, patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.accounts import AccountManager @@ -65,7 +66,7 @@ def audit(*args,**kwargs): if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('[data-page="coverage"]').click() + open_page(page, 'coverage') expect(page.locator('[data-dimension]')).to_have_count(8) expect(page.locator('[data-feature]')).to_have_count(30) expect(page.locator('[data-feature="components"]')).to_contain_text('原生实现') @@ -78,7 +79,7 @@ def audit(*args,**kwargs): page.screenshot(path=str(out/f'{width}-coverage.png'),full_page=True) page.locator('[data-dimension="device"] button').first.click() expect(page.locator('[data-inspection="device.environment"]')).to_be_visible() - page.locator('[data-page="accounts"]').click() + open_page(page, 'accounts') page.locator('#encrypted-slot').fill('B') page.locator('#encrypted-path').fill(str(home/'fixture.envsnap')) page.locator('#encrypted-password').fill('fixture-ui-password-123') @@ -156,7 +157,7 @@ def audit(*args,**kwargs): expect(page.locator('#account-output')).to_contain_text('已校验并导入',timeout=20000) expect(page.locator('#encrypted-password')).to_have_value('') assert (source/'fixture.txt').read_bytes()==(manager.accounts_dir/'C/fixture.txt').read_bytes() - page.locator('[data-page="tools"]').click() + open_page(page, 'tools') page.locator('#maintenance-action').select_option('open-safe') expect(page.locator('#maintenance-run')).to_be_enabled() page.locator('#maintenance-run').click() diff --git a/scripts/desktop_ui_smoke.py b/scripts/desktop_ui_smoke.py index cb997b4..66d4241 100644 --- a/scripts/desktop_ui_smoke.py +++ b/scripts/desktop_ui_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.check_catalog import check_catalog from envscope.model import Finding, Status @@ -85,7 +86,7 @@ def audit(*args, **kwargs): page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') if page.locator('#learning-panel').is_visible(): page.locator('#learning-skip').click() - page.locator('[data-page="settings"]').click() + open_page(page, 'settings') expect(page.locator('#desktop-mode')).to_contain_text('独立软件正在运行') ids = page.locator('[id]').evaluate_all('(nodes)=>nodes.map(n=>n.id)') assert len(ids) == len(set(ids)) @@ -107,11 +108,12 @@ def audit(*args, **kwargs): page.locator('#app-update-rollback').click() expect(page.locator('#software-output')).to_contain_text('已完成') page.screenshot(path=str(out / f'{width}-settings.png'), full_page=True) - page.locator('[data-page="audit"]').click() + open_page(page, 'audit') page.locator('#start').click() + open_details(page, '#more-results') expect(page.locator('#qualification')).to_be_visible() expect(page.locator('#qualification-counts')).to_contain_text('需要读取权限 1 项') - page.locator('[data-page="checks"]').click() + open_page(page, 'checks') page.locator('#check-status').select_option('unknown') expect(page.locator('[data-inspection="dns.leak"]')).to_have_count(0) expect(page.locator('[data-inspection="extra.cli_paths"]')).to_have_count(0) diff --git a/scripts/generate_region_data.py b/scripts/generate_region_data.py new file mode 100644 index 0000000..83bc202 --- /dev/null +++ b/scripts/generate_region_data.py @@ -0,0 +1,84 @@ +"""Generate offline region presets from a pinned Unicode CLDR release (no runtime fetch).""" +import argparse +import hashlib +import json +from pathlib import Path +import re +from urllib.request import urlopen +import xml.etree.ElementTree as ET + +REVISION = 'acd6d88ae493633240e19a87a721076a8a75c310' # CLDR release-48 +BASE = f'https://raw.githubusercontent.com/unicode-org/cldr/{REVISION}/' +FILES = ('common/supplemental/windowsZones.xml', 'common/supplemental/likelySubtags.xml', + 'common/main/zh.xml', 'common/bcp47/timezone.xml', 'LICENSE') +EXPECTED_SHA256 = dict(zip(FILES, ( + '9cf3db6a31fb382fee21b70be6feba1e82766b0fcd06e6261fb7936a73e537ff', + 'd7732172848cc2870d0d316818190e704bc6614486168dc52af56ee1427b6860', + 'b1ef8bcadcf19fa8914d9ba812a7bcec124c72fecbf4acea02e4c8bd2fe51866', + '54ce7d9f1d385101e24356df7d48ae62cf2acc4421378da60a4007d2488c1aad', + 'b4c0ae8ef04f7059f96ce5bbe0467f9fe6f6d81bbe13517701dfeb961fb4d0b6', +))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + args.cache.mkdir(parents=True, exist_ok=True) + sources, digests = {}, {} + for name in FILES: + cached = args.cache / name.replace('/', '_') + if not cached.exists(): + with urlopen(BASE + name, timeout=30) as response: + cached.write_bytes(response.read(4_000_000)) + content = cached.read_bytes() + sources[name] = content + digests[name] = hashlib.sha256(content).hexdigest() + if digests[name] != EXPECTED_SHA256[name]: + raise ValueError('Pinned CLDR source digest mismatch: ' + name) + zh = ET.fromstring(sources['common/main/zh.xml']) + names = {n.attrib['type']: n.text for n in zh.findall('./localeDisplayNames/territories/territory') + if re.fullmatch('[A-Z]{2}', n.attrib['type']) and not n.attrib.get('alt') + and n.attrib['type'] not in ('ZZ', 'QO', 'EU', 'EZ', 'UN', 'XA', 'XB')} + # Region naming is presentation only, independent of the product availability list. + names.update(CN='中国大陆', HK='中国香港', MO='中国澳门', TW='中国台湾') + cities = {n.attrib['type']: n.findtext('exemplarCity') for n in zh.findall('./dates/timeZoneNames/zone')} + likely = {n.attrib['from']: n.attrib['to'] for n in + ET.fromstring(sources['common/supplemental/likelySubtags.xml']).iter('likelySubtag')} + aliases = {} + for node in ET.fromstring(sources['common/bcp47/timezone.xml']).iter('type'): + zones = node.attrib.get('alias', '').split() + if zones: + for zone in zones: + aliases[zone] = tuple(zones) + windows = ET.fromstring(sources['common/supplemental/windowsZones.xml']) + rows = [] + for node in windows.iter('mapZone'): + country = node.attrib['territory'] + if country not in names: + continue + language = likely.get('und_' + country, '').split('_')[0] + for zone in node.attrib['type'].split(): + city = cities.get(zone) or next((cities[a] for a in aliases.get(zone, ()) if cities.get(a)), None) + city = city or zone.rsplit('/', 1)[-1].replace('_', ' ') + key = country.lower() + '-' + re.sub('[^a-z0-9]+', '-', zone.lower()).strip('-') + locale = language + '-' + country if language else '' + rows.append((key, names[country] + ' · ' + city, country, zone, + node.attrib['other'], locale, aliases.get(zone, (zone,)))) + rows.sort(key=lambda row: (row[2], row[3])) + content = ('"""Generated CLDR 48 region data. Unicode-3.0; see licenses/Unicode-3.0.txt.\n' + 'Regenerate using scripts/generate_region_data.py. Preferences, not location evidence.\n"""\n\n' + f'CLDR_REVISION = {REVISION!r}\n' + f'SOURCE_SHA256 = {digests!r}\n\nCOUNTRIES = (\n' + + ''.join(' ' + repr((code, name)) + ',\n' for code, name in sorted(names.items())) + + ')\n\nREGIONS = (\n' + ''.join(' ' + repr(row) + ',\n' for row in rows) + ')\n') + (root / 'envscope/region_data.py').write_text(content, encoding='utf-8', newline='\n') + (root / 'licenses').mkdir(exist_ok=True) + (root / 'licenses/Unicode-3.0.txt').write_bytes(sources['LICENSE']) + print(json.dumps({'countries': len(names), 'presets': len(rows), + 'countries_with_presets': len({r[2] for r in rows}), 'revision': REVISION})) + + +if __name__ == '__main__': + main() diff --git a/scripts/language_smoke.py b/scripts/language_smoke.py index e0d9652..7767dce 100644 --- a/scripts/language_smoke.py +++ b/scripts/language_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.language_repair import LanguageIO,LanguageRepair @@ -44,13 +45,13 @@ def audit(*args,**kwargs): page=context.new_page();errors=[];page.on('pageerror',lambda error:errors.append(str(error))) page.add_init_script('window.RTCPeerConnection=undefined;');page.route('https://**/*',lambda request:request.abort()) def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) - def upkeep():idle();page.locator('.tab[data-page=upkeep]').click() + def upkeep():idle();open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();idle() if system=='Windows': - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') row=page.locator('[data-inspection="baseline.language"]');row.locator(':scope > summary').click() expect(row).to_have_attribute('data-manual','true');expect(row).to_contain_text('Windows 尚未提供') upkeep();expect(page.locator('#language-prepare')).to_be_disabled() diff --git a/scripts/process_guard_smoke.py b/scripts/process_guard_smoke.py index 0fe263a..f138564 100644 --- a/scripts/process_guard_smoke.py +++ b/scripts/process_guard_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.process_guard import ProcessGuard @@ -40,7 +41,7 @@ def main(): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') if mode=='tun': page.locator('#process-guard-load').click() expect(page.locator('#process-guard-interface')).to_contain_text('utun9') diff --git a/scripts/region_lookup_smoke.py b/scripts/region_lookup_smoke.py index 04f6746..f18653f 100644 --- a/scripts/region_lookup_smoke.py +++ b/scripts/region_lookup_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading import time +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.service import make_server @@ -36,8 +37,8 @@ def lookup(params): country=page.locator('#target-form [name=usage_country]') city=page.locator('#target-region-profile');status=page.locator('#target-ip-status') country.fill('US');expect(status).to_contain_text('仅显示 US') - assert city.locator('option[value^="us-"]').count()==7 - assert city.locator('option').count()==8 + assert city.locator('option[value^="us-"]').count()>=29 + assert city.locator('option').count()==city.locator('option[value^="us-"]').count()+1 expect(city).to_have_value('us-new-york') expect(page.locator('#target-form [name=timezone]')).to_have_value('Eastern Standard Time' if system=='Windows' else 'America/New_York') expect(page.locator('#target-form [name=language]')).to_have_value('en-US') @@ -58,7 +59,7 @@ def lookup(params): # Stale route response is discarded; the queued route is queried after it. delay[0]=0.4;page.locator('#target-read-ip').click();expect(status).to_contain_text('正在按所选') fixture.update(country='JP',timezone='Asia/Tokyo') - page.locator('#target-form [name=connection_mode]').select_option('tun') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('tun') expect(status).to_contain_text('仅显示 JP');expect(page.locator('#target-confirm')).to_be_enabled() assert calls[-1]['connection_mode']=='tun' fixture.clear();fixture.update(state='conflict',reason='模拟来源国家冲突');delay[0]=0 diff --git a/scripts/routing_smoke.py b/scripts/routing_smoke.py index d2a3b97..7ded27d 100644 --- a/scripts/routing_smoke.py +++ b/scripts/routing_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations @@ -36,7 +37,7 @@ def main(): page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') if page.locator('#learning-skip').is_visible(): page.locator('#learning-skip').click() page.locator('#start').click(); expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-load').click() expect(page.locator('#routing-group')).to_contain_text('Claude') page.locator('#routing-group').select_option('Claude') @@ -49,21 +50,21 @@ def main(): page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('上次加载已验证', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-restore').click() expect(page.locator('#routing-plan')).to_contain_text('恢复此次路由修改前') assert len(core.calls) == 1 page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('已恢复原配置', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-prepare').click() expect(page.locator('#routing-plan button')).to_be_visible() core.fail_load = True page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('需要继续恢复', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#routing-status')).to_have_class('note manual-guidance') expect(page.locator('#routing-prepare')).to_be_disabled() expect(page.locator('#routing-restore')).to_be_enabled() diff --git a/scripts/selection_smoke.py b/scripts/selection_smoke.py index 766fd95..0870846 100644 --- a/scripts/selection_smoke.py +++ b/scripts/selection_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.legacy import LegacyBridge from envscope.model import Status @@ -84,8 +85,8 @@ def block(route): page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') page.locator('#learning-skip').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('cli') - page.locator('#target-form [name=connection_mode]').select_option('tun') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('tun') page.locator('#target-form [name=usage_country]').fill('US') page.locator('#target-region-profile').select_option('us-new-york') desired = 'Eastern Standard Time' if system == 'Windows' else 'America/New_York' @@ -95,7 +96,7 @@ def block(route): expect(page.locator('#start')).to_be_disabled() expect(page.locator('#start')).to_be_enabled(timeout=30000) expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') page.locator('#check-search').fill('时区') page.locator('#checks-clear').click() page.locator('#checks-select-all').click() @@ -129,8 +130,8 @@ def block(route): expect(row.locator('.inspection-state')).to_contain_text('通过', timeout=30000) expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') assert writes == [['timezone', desired]] - page.locator('.tab[data-page=audit]').click() - page.locator('#target-configure').click() + open_page(page, 'audit') + open_page(page, 'checks'); page.locator('#target-configure').click() expect(page.locator('#checks-node-note')).to_contain_text('名称不证明') expect(page.locator('#checks-region-undo')).to_be_visible() expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') diff --git a/scripts/simple_workflow_smoke.py b/scripts/simple_workflow_smoke.py new file mode 100644 index 0000000..14fbc3a --- /dev/null +++ b/scripts/simple_workflow_smoke.py @@ -0,0 +1,144 @@ +"""End-to-end desktop workflow using disposable settings, files and OS adapters.""" +import json +from pathlib import Path +import tempfile +import threading +from unittest.mock import Mock + +from playwright.sync_api import expect, sync_playwright +from envscope.legacy import LegacyBridge +from envscope.maintenance import Maintenance +from envscope.model import Status +from envscope.operations import Operations +from envscope.region_setup import RegionSetup +from envscope.service import make_server +from envscope.state import StateStore +from envscope.watchdog import Watchdog +from scripts.environment_fixtures import fixture +from scripts.ui_navigation import open_page, open_details +from tests.test_maintenance import FakeScheduler + + +def main(): + out = Path('artifacts/simple-workflow-ui') + out.mkdir(parents=True, exist_ok=True) + results = [] + with sync_playwright() as runtime: + browser = runtime.chromium.launch() + try: + for system, width in (('Windows', 1024), ('Windows', 1440), ('Darwin', 1280)): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary).resolve() + store = StateStore(home / 'state') + inventory = {'platform': system, 'locale': 'zh-CN', 'network': { + 'timezone_id': 'China Standard Time' if system == 'Windows' else 'Asia/Shanghai'}} + writes, errors, requests = [], [], [] + fault = [None] + + def writer(key, value): + writes.append((key, value)) + if key == 'timezone': + inventory['network']['timezone_id'] = value + else: + inventory[key] = value + + def auditor(*args, **kwargs): + settings = kwargs.get('baseline_config') or store.settings() + rows = fixture(home, settings, system, fault=fault[0]) + for row in rows: + if row.id in ('baseline.timezone', 'baseline.locale'): + key = row.id.split('.')[1] + actual = inventory['network']['timezone_id'] if key == 'timezone' else inventory['locale'] + desired = settings[key] + row.status = Status.PASS if actual.replace('_', '-') == desired.replace('_', '-') else Status.FAIL + row.summary = '符合目标' if row.status == Status.PASS else '当前为 ' + actual + ',目标为 ' + desired + row.evidence = [{'actual': actual, 'expected': desired}] + return rows + + guard = Watchdog(home, system, reader=lambda: inventory, writer=writer, clients=lambda: []) + maintenance = Maintenance(store, home=home, scheduler=FakeScheduler(), watchdog=guard, auditor=auditor) + ops = Operations(store, home, maintenance=maintenance, discoverer=lambda: [], + environment_reader=lambda: inventory, + legacy=LegacyBridge(home=home, runner=Mock(side_effect=AssertionError('No vendor execution'))), + region_lookup=lambda params: {'state': 'observed', 'country': 'US', 'ip': '8.8.8.8', + 'timezone': 'America/New_York', 'reason': '离线模拟'}) + ops.regions = RegionSetup(store, lambda: inventory, home, system, writer) + ops.config_remedies.environ = {} + server = make_server(store=store, operations=ops, auditor=auditor) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + context = browser.new_context(viewport={'width': width, 'height': 950}, locale='zh-CN') + page = context.new_page() + page.on('pageerror', lambda e: errors.append(str(e))) + page.add_init_script('window.RTCPeerConnection=undefined') + page.route('https://**/*', lambda route: route.abort()) + page.on('request', lambda request: requests.append(request.post_data_json) if request.url.endswith('/api/action') else None) + try: + page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') + expect(page.locator('#audit-content')).to_be_visible() + expect(page.locator('#learning-panel')).to_be_visible() + expect(page.locator('#audit-results')).to_be_hidden() + assert page.locator('#desktop-nav .tab:visible').count() == 1 + expect(page.locator('#target-confirm')).to_be_enabled() + assert page.locator('#target-country option').count() > 240 + page.screenshot(path=str(out / f'{system}-{width}-initial.png'), full_page=True) + page.locator('#learning-skip').click() + page.locator('[name=usage_country]').fill('US') + expect(page.locator('#target-region-profile')).to_have_value('us-new-york') + open_details(page, '#target-connection-options') + page.locator('#target-form [name=usage]').select_option('cli') + page.locator('#target-form [name=connection_mode]').select_option('direct') + page.locator('#target-connection-options>summary').click() + page.locator('#target-confirm').click() + expect(page.locator('#apply-solution')).to_be_visible(timeout=30000) + expect(page.locator('#solution-plan')).to_contain_text('时区') + assert not writes, 'preview must not write' + assert page.locator('#problems>details').count() >= 2 + assert page.locator('#problems>details>summary:visible').count() == page.locator('#problems>details').count() + expect(page.locator('#flow-upkeep')).to_be_visible() + expect(page.locator('#more-results')).not_to_have_attribute('open', '') + page.screenshot(path=str(out / f'{system}-{width}-problems.png'), full_page=True) + page.locator('#apply-solution').click() + expect(page.locator('#resolution')).to_be_visible(timeout=30000) + expect(page.locator('#start')).to_be_enabled() + assert {key for key, value in writes} == {'timezone', 'locale'} + assert not page.locator('#problems [data-problem="baseline.timezone"]').count() + page.locator('#flow-upkeep-guard-timezone').check() + page.locator('#flow-upkeep-prepare').click() + expect(page.locator('#flow-upkeep-plan')).to_contain_text('保持时区') + assert not maintenance.scheduler.definition + page.locator('#flow-upkeep-plan button').click() + expect(page.locator('#flow-upkeep-status')).to_contain_text('等待', timeout=10000) + assert maintenance.scheduler.definition + page.locator('#flow-upkeep-stop').click() + expect(page.locator('#flow-upkeep-status')).to_contain_text('尚未启用') + fault[0] = 'permission' + page.locator('#start').click() + expect(page.locator('#start')).to_be_enabled(timeout=30000) + expect(page.locator('#problems')).to_contain_text('权限') + assert page.locator('#problems>details[data-manual=true]>summary:visible').count() > 0 + page.screenshot(path=str(out / f'{system}-{width}-permission.png'), full_page=True) + for name in ('accounts', 'tools', 'checks', 'upkeep', 'settings', 'coverage', 'history', 'audit'): + open_page(page, name) + expect(page.locator('#page-' + name)).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + assert any(r['action'] == 'inspection-plan' for r in requests) + assert all(r['confirmed'] is False for r in requests if r['action'].endswith('-plan')) + assert not errors, errors + results.append({'platform': system, 'width': width, 'single_workflow': True, + 'all_issue_summaries_visible': True, 'preview_before_write': True, + 'repair_retest': True, 'watchdog_preview_enable_disable': True, + 'advanced_navigation': True, 'production_changes': False}) + finally: + context.close() + server.shutdown() + server.server_close() + thread.join(3) + finally: + browser.close() + (out / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(results, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/scripts/software_smoke.py b/scripts/software_smoke.py index 7980b6d..1a11b04 100644 --- a/scripts/software_smoke.py +++ b/scripts/software_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright,expect from envscope.legacy_components import ComponentMigration from envscope.legacy_software import SoftwareCleanup @@ -49,7 +50,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#software-state')).to_have_text('尚未清理',timeout=30000);idle() expect(page.locator('#software-all')).to_be_checked() expect(page.locator('#software-status')).to_have_class('note manual-guidance') diff --git a/scripts/system_network_smoke.py b/scripts/system_network_smoke.py index ec2dd04..ddc74c3 100644 --- a/scripts/system_network_smoke.py +++ b/scripts/system_network_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.operations import Operations @@ -50,7 +51,7 @@ def audit(*args,**kwargs): page.route('https://**/*',lambda request:request.abort()) def upkeep(): expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() diff --git a/scripts/target_environment_smoke.py b/scripts/target_environment_smoke.py index d21b555..70562cc 100644 --- a/scripts/target_environment_smoke.py +++ b/scripts/target_environment_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.legacy import LegacyBridge from envscope.operations import Operations @@ -56,15 +57,16 @@ def block(route): page.locator('#learning-skip').click() expect(page.locator('#target-form')).to_be_visible() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#start').click() + expect(page.locator('#start')).to_be_hidden() + page.locator('#target-confirm').click() assert not calls and not outbound and code.read_bytes() == original token = context.request.get(origin+'/api/session').json()['token'] snapshot = {'schema_version':1,'kind':'envscope-browser-observations','contains_sensitive':True, 'created_at':utc_now(),'http':[],'ice':[],'ice_complete':False} denied = context.request.post(origin+'/api/audit', data={'snapshot':snapshot}, headers={'Origin':origin,'X-EnvScope-Token':token}) assert denied.status == 409 and not calls - page.locator('#target-form [name=usage]').select_option('cli') - page.locator('#target-form [name=connection_mode]').select_option('direct') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('direct') expect(page.locator('#target-form [name=timezone]')).to_have_value('') expect(page.locator('#target-form [name=locale]')).to_have_value('') expect(page.locator('#target-form [name=usage_country]')).to_have_value('') @@ -111,20 +113,20 @@ def block(route): expect(page.locator('#apply-solution')).to_have_count(0) page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=connection_mode]').select_option('http_proxy') - page.locator('#target-form [name=target_proxy]').fill(PROXY) + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('http_proxy') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=target_proxy]').fill(PROXY) page.locator('#target-cancel').click() assert store.settings()['connection_mode'] == 'direct' page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('web') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('web') page.locator('#target-confirm').click() expect(page.locator('#target-form')).to_be_hidden() expect(page.locator('#target-summary')).to_contain_text('网页版') expect(page.locator('#verdict')).to_have_text('所选目标的环境检查合格') assert code.read_bytes() == repaired, 'web selection must not rewrite Code' assert store.settings()['usage'] == 'web' - page.locator('[data-page=checks]').click() + open_page(page, 'checks') row = page.locator('[data-inspection="extra.cli_paths"]') expect(row).to_contain_text('不适用') page.locator('#check-status').select_option('excluded') diff --git a/scripts/timezone_service_smoke.py b/scripts/timezone_service_smoke.py index 8c54b8d..5142a2b 100644 --- a/scripts/timezone_service_smoke.py +++ b/scripts/timezone_service_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading import time +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.operations import Operations @@ -42,7 +43,7 @@ def main(): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') page.locator('#timezone-service-prepare').click() expect(page.locator('#timezone-service-plan')).to_contain_text('Asia/Shanghai 配置为 America/New_York') @@ -50,20 +51,20 @@ def main(): page.locator('#timezone-service-panel').screenshot(path=str(output/f'{width}-preview.png')) page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常',timeout=10000) assert len(io.scripts)==1 page.locator('#timezone-service-undo').click() expect(page.locator('#timezone-service-plan')).to_contain_text('Asia/Shanghai') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') assert world['timezone_iana']=='Asia/Shanghai' page.locator('#timezone-service-prepare').click() page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常') io.interrupt=True page.locator('#timezone-service-prepare').click() @@ -77,17 +78,17 @@ def main(): expect(page.locator('#timezone-service-plan')).to_contain_text('恢复上次保存的服务文件') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常') page.locator('#timezone-service-restore').check() page.locator('#timezone-service-stop').click() expect(page.locator('#timezone-service-plan')).to_contain_text('恢复安装前的系统时区:Asia/Shanghai') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') ops.timezone_service.system='Windows' - page.reload(wait_until='networkidle');page.locator('.tab[data-page=upkeep]').click() + page.reload(wait_until='networkidle');open_page(page, 'upkeep') expect(page.locator('#timezone-service-panel')).to_be_hidden() assert not errors,errors assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') diff --git a/scripts/ui_navigation.py b/scripts/ui_navigation.py new file mode 100644 index 0000000..e633a7c --- /dev/null +++ b/scripts/ui_navigation.py @@ -0,0 +1,14 @@ +"""Use the actual visible menu in desktop UI acceptance tests.""" +from playwright.sync_api import expect + + +def open_page(page, name): + expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true', timeout=30000) + if name != 'audit' and not page.locator('#more-menu').evaluate('(node)=>node.open'): + page.locator('#more-menu>summary').click() + page.locator(f'.tab[data-page="{name}"]').click() + + +def open_details(page, selector): + if not page.locator(selector).evaluate('(node)=>node.open'): + page.locator(selector + '>summary').click() diff --git a/scripts/usability_smoke.py b/scripts/usability_smoke.py index be47def..5c197f5 100644 --- a/scripts/usability_smoke.py +++ b/scripts/usability_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.accounts import AccountManager from envscope.check_catalog import check_catalog @@ -71,7 +72,7 @@ def audit(*args, **kwargs): if page.locator('#learning-panel').is_visible(): page.locator('#learning-skip').click() expect(page.locator('#connection-status')).to_contain_text('已连接') - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('[data-inspection]')).to_have_count(57) expect(page.locator('#all-checks-context')).to_contain_text('尚未检测') page.locator('#check-group').select_option('baseline') @@ -95,7 +96,7 @@ def audit(*args, **kwargs): page.screenshot(path=str(OUT / f'{width}-checks.png')) page.locator('#check-group').select_option('all') - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') with patch('envscope.operations.cli_information', side_effect=lambda path: cli_information(path, which=lambda n: None)): page.locator('#cli-info').click() expect(page.locator('#environment-output')).to_contain_text('当前进程 PATH 中未找到') @@ -115,7 +116,7 @@ def audit(*args, **kwargs): payload = json.loads(Path(downloaded.value.path()).read_text(encoding='utf-8')) assert payload['contains_sensitive'] is False - page.get_by_role('button', name='账号', exact=True).click() + open_page(page, 'accounts') expect(page.locator('.account-guide')).to_contain_text('不是邮箱或已注册账号') expect(page.locator('#account-force-close')).not_to_be_checked() page.locator('#new-slot').fill('错误代号') @@ -156,21 +157,21 @@ def approve_force(dialog): assert (manager.data_dir / 'fixture.txt').read_text() == 'ACCOUNT_A_FIXTURE' assert (manager.accounts_dir / 'B/fixture.txt').read_text() == 'ACCOUNT_B_FIXTURE' page.screenshot(path=str(OUT / f'{width}-accounts.png'), full_page=True) - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') page.locator('#check-status').select_option('pass') assert page.locator('[data-inspection]').count() > 0 page.locator('#check-status').select_option('all') expect(page.locator('#all-checks-context')).to_contain_text('本次检测') - page.get_by_role('button', name='历史', exact=True).click() + open_page(page, 'history') page.locator('#history-list').get_by_role('button', name='查看', exact=True).first.click() - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('历史报告') - page.get_by_role('button', name='检测', exact=True).click() + open_page(page, 'audit') page.route('**/api/audit', lambda route: route.fulfill(status=503, content_type='application/json', body='{"error":"fixture_failure"}')) page.locator('#start').click() expect(page.locator('#verdict')).to_contain_text('系统检测没有完成') - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('本轮检测没有完成') page.locator('#check-status').select_option('pass') expect(page.locator('[data-inspection]')).to_have_count(0) @@ -178,7 +179,7 @@ def approve_force(dialog): expect(page.locator('[data-inspection]')).to_have_count(57) page.unroute('**/api/audit') - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') page.route('**/api/action', lambda route: route.fulfill(status=500, content_type='application/json', body='{"error":"operation_unavailable_or_deadline"}')) page.locator('#device-info').click() expect(page.locator('#environment-output')).to_contain_text('本次读取未能完成') @@ -197,12 +198,12 @@ def approve_force(dialog): expect(page.locator('#connection-banner')).to_be_hidden() page.locator('#browser-info').click() expect(page.locator('#environment-output')).to_contain_text('浏览器信息') - for name in ('检测', '全部检查', '账号', '工具', '持续维护', '历史', '设置'): - page.get_by_role('button', name=name, exact=True).click() + for name in ('audit', 'checks', 'accounts', 'tools', 'upkeep', 'history', 'settings'): + open_page(page, name) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth'), name - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') page.locator('#project-comparison>summary').click() - expect(page.locator('#project-comparison')).to_contain_text('仍需维护核心或手动处理') + expect(page.locator('#project-comparison')).to_contain_text('完整功能升级核对表') # A visible, idle page renews activity without running diagnostics. page.clock.install() server.last_activity = time.monotonic() - 901 @@ -214,7 +215,7 @@ def approve_force(dialog): page.route('**/api/catalog', lambda route: route.fulfill(status=500, content_type='application/json', body='{"error":"catalog_unavailable"}')) page.reload(wait_until='networkidle') expect(page.locator('#catalog-error')).to_be_visible() - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') with patch('envscope.operations.cli_information', side_effect=lambda path: cli_information(path, which=lambda n: None)): page.locator('#cli-info').click() expect(page.locator('#environment-output')).to_contain_text('当前进程 PATH 中未找到') diff --git a/scripts/verify_package.py b/scripts/verify_package.py index 9e7143e..e4cbc24 100644 --- a/scripts/verify_package.py +++ b/scripts/verify_package.py @@ -25,6 +25,7 @@ def verify(read, names): assert read("envscope/web/" + path), "missing UI resource" assert read('envscope/web/components_ui.mjs'), 'missing component migration UI' assert read('envscope/web/check_preferences_ui.mjs'), 'missing optional checks / diagnostics UI' + assert read('envscope/web/workflow.mjs'), 'missing single-workflow UI' print("Verified full pinned vendor bundle and all desktop UI resources.") @@ -39,6 +40,8 @@ def main(): return with zipfile.ZipFile(target) as wheel: assert wheel.read('envscope/csnap_archive.py'), 'missing CSNAP001 compatibility module' + assert wheel.read('envscope/region_data.py'), 'missing offline CLDR region presets' + assert any(n.endswith('/licenses/licenses/Unicode-3.0.txt') for n in wheel.namelist()), 'missing Unicode license' metadata=[n for n in wheel.namelist() if n.endswith('.dist-info/METADATA')] assert len(metadata)==1,'Missing or ambiguous package metadata' values=BytesParser().parsebytes(wheel.read(metadata[0])) diff --git a/scripts/watchdog_smoke.py b/scripts/watchdog_smoke.py index 0d867e9..bbdaf31 100644 --- a/scripts/watchdog_smoke.py +++ b/scripts/watchdog_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.maintenance import Maintenance from envscope.operations import Operations @@ -46,7 +47,7 @@ def writer(key,value): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#upkeep-egress')).to_contain_text('尚无有效出口观测') page.locator('#upkeep-guard-timezone').check() page.locator('#upkeep-prepare').click();expect(page.locator('#upkeep-plan')).to_contain_text('保持时区') diff --git a/scripts/wireguard_smoke.py b/scripts/wireguard_smoke.py index 4ea5fe5..99efd0b 100644 --- a/scripts/wireguard_smoke.py +++ b/scripts/wireguard_smoke.py @@ -7,6 +7,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.model import Status @@ -55,7 +56,7 @@ def auditor(*args, **kw): page.add_init_script('window.RTCPeerConnection=undefined;'); page.route('https://**/*', lambda r: r.abort()) def idle(): expect(page.locator('#start')).to_be_enabled(timeout=30000) def all_checks(): - idle(); page.locator('.tab[data-page=checks]').click() + idle(); open_page(page, 'checks') return page.locator('[data-inspection="target.wireguard"]') try: page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') @@ -63,6 +64,7 @@ def all_checks(): page.locator('#target-edit').click() expect(page.locator('#target-observed')).to_contain_text('Windows') expect(page.locator('#target-confirm')).to_be_enabled(timeout=15000) + open_details(page, '#target-connection-options') page.locator('#target-form select[name=client]').select_option('wireguard') expect(page.locator('#target-wireguard')).to_be_visible() expect(page.locator('#target-form select[name=connection_mode]')).to_have_value('tun') @@ -78,7 +80,7 @@ def all_checks(): assert not scheduler.calls and not io.stops # Manual controls also select the single matching NIC, never # offer another active VPN as a substitute for this service. - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#process-guard-load').click() expect(page.locator('#process-guard-interface')).to_have_value('EnvScope Fixture US') expect(page.locator('#process-guard-interface')).not_to_contain_text('Other VPN') @@ -95,16 +97,16 @@ def all_checks(): guard.pulse(); page.locator('#process-guard-refresh').click() expect(page.locator('#process-guard-status')).to_contain_text('所选 WireGuard 隧道服务离线') assert len(io.stops) == 1 - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click() + open_page(page, 'audit'); page.locator('#start').click() expect(all_checks()).to_contain_text('所选 WireGuard 隧道服务未运行') page.locator('[data-inspection="target.wireguard"]').screenshot(path=str(output/f'{width}-offline.png')) services['services'][0].update(observed()['services'][0]); route[0] = 'Other VPN' - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click(); all_checks() + open_page(page, 'audit'); page.locator('#start').click(); all_checks() expect(page.locator('[data-inspection="target.tun_route"]')).to_contain_text('目标地址存在非隧道选路') services['services'][0]['error'] = 'permission_denied' - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click() + open_page(page, 'audit'); page.locator('#start').click() expect(all_checks()).to_contain_text('系统拒绝读取所选 WireGuard 服务或进程') - page.locator('.tab[data-page=upkeep]').click(); page.locator('#process-guard-stop').click() + open_page(page, 'upkeep'); page.locator('#process-guard-stop').click() expect(page.locator('#process-guard-state')).to_have_text('未启用') assert not scheduler.definition and not errors assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') diff --git a/tests/browser.test.mjs b/tests/browser.test.mjs index 3881504..d1db6b4 100644 --- a/tests/browser.test.mjs +++ b/tests/browser.test.mjs @@ -1,8 +1,55 @@ import test from "node:test"; import {describeProxy} from "../envscope/web/diagnosis.mjs"; import assert from "node:assert/strict"; +import {workflowIssues,workflowChecks} from '../envscope/web/workflow.mjs'; +import {suggestedCity,cityChoices} from '../envscope/web/region_choices.mjs'; import {normalizeIP,isPublicIP,parseCandidate,assessHTTP,assessICE,sharingSnapshot} from "../envscope/web/logic.mjs"; +test('required permission and missing evidence stay visible without a diagnosis problem',()=>{ + const report={findings:[{id:'target.config',title:'配置读取',summary:'权限被拒绝'}],diagnosis:{problems:[]}, + assessment:{items:[{id:'target.config',required:true,state:'permission',action:'permission-help',next_step:'授权后重试'}, + {id:'target.dns',title:'域名解析',required:true,state:'unavailable',next_step:'重新检测'}, + {id:'dns.leak',required:false,state:'unsupported'}]}}; + const issues=workflowIssues(report); + assert.deepEqual(issues.map(i=>i.id),['target.config','target.dns']); + assert.equal(issues[0].action,'permission-help');assert.equal(issues[0].automatic,false); + assert.equal(issues[1].observed,'本轮未取得必需证据'); +}); + +test('one problem mapped by several required checks is not repeated',()=>{ + const report={diagnosis:{problems:[{id:'config.cli',repair:'json-cli',title:'配置格式错误'}]}, + assessment:{items:[{id:'target.config',required:true,state:'problem'}]}, + repair_options:{'target.config':{action:'solution',problem_ids:['config.cli']},'extra.config':{action:'solution',problem_ids:['config.cli']}}}; + assert.equal(workflowIssues(report).length,1);assert.equal(workflowIssues(report)[0].automatic,true); +}); + +test('one-click repair excludes passed, unselected and informational checks',()=>{ + const ids=['baseline.timezone','baseline.guard','target.dns','extra.config']; + const report={selection:{selected:ids},findings:[{id:ids[0],status:'warn'}, + {id:ids[1],status:'unknown'},{id:ids[2],status:'fail',scope:'not-selected'},{id:ids[3],status:'pass'}], + assessment:{items:[{id:ids[0],required:true,state:'review'},{id:ids[1],required:false,state:'informational'}]}, + repair_options:Object.fromEntries(ids.map(id=>[id,{needs_attention:id!==ids[3],action:'solution',problem_ids:[]}]))}; + assert.deepEqual(workflowChecks(report),['baseline.timezone']); +}); + +test('IP timezone aliases are country-bound and partial lookup never auto-selects',()=>{ + const india={id:'india',country:'IN',iana:'Asia/Calcutta',aliases:['Asia/Calcutta','Asia/Kolkata']}; + const observation={state:'observed',country:'IN',timezone:'Asia/Kolkata'}; + assert.equal(suggestedCity([india],observation),india); + assert.equal(suggestedCity([india],{...observation,state:'partial'}),null); + assert.equal(suggestedCity([india],{...observation,country:'US'}),null); + assert.deepEqual(cityChoices([india],'US'),[]); +}); + +test('reviewed Code setup keeps measured scope and excludes web-only usage',()=>{ + const report={usage:'cli',selection:{selected:['extra.cli_env']},findings:[{id:'extra.cli_env',status:'pass'}]}; + assert.deepEqual(workflowChecks(report),[]); + assert.deepEqual(workflowChecks(report,true),['extra.cli_env']); + assert.deepEqual(workflowChecks({...report,usage:'web'},true),[]); + assert.deepEqual(workflowChecks({...report,selection:{selected:[]}},true),[]); + assert.deepEqual(workflowChecks({...report,findings:[{id:'extra.cli_env',scope:'not-selected'}]},true),[]); +}); + test("IPv6 equivalent spellings and mapped IPv4 canonicalize",()=>{ assert.equal(normalizeIP("2001:4860::1"),normalizeIP("2001:4860:0:0:0:0:0:1")); assert.equal(normalizeIP("::ffff:8.8.8.8"),"8.8.8.8"); diff --git a/tests/test_region_catalog.py b/tests/test_region_catalog.py new file mode 100644 index 0000000..fc4d11b --- /dev/null +++ b/tests/test_region_catalog.py @@ -0,0 +1,32 @@ +from unittest import TestCase + +from envscope.region_profiles import countries, profiles, profile, validate_profile + + +class RegionCatalogTests(TestCase): + def test_common_node_countries_have_real_timezone_mappings_on_both_platforms(self): + expected = {'NL', 'IE', 'CH', 'IT', 'ES', 'SE', 'FI', 'NO', 'PL', 'IN', 'BR', + 'ZA', 'TH', 'MY', 'NZ', 'ID', 'AE', 'PH', 'VN', 'CN', 'RU'} + self.assertTrue(expected <= {c['code'] for c in countries()}) + for system in ('Windows', 'Darwin'): + rows = profiles(system) + self.assertGreaterEqual(len(rows), 400) + self.assertEqual(len(rows), len({r['id'] for r in rows})) + self.assertTrue(expected <= {r['country'] for r in rows}) + for row in rows: + validate_profile(dict(region_profile=row['id'], **{k: row[k] for k in ('country', 'timezone', 'locale')})) + self.assertNotIn('usage_country', row) + + def test_saved_ids_are_stable_and_timezone_aliases_are_available(self): + self.assertEqual(profile('us-new-york', 'Windows')['timezone'], 'Eastern Standard Time') + self.assertEqual(profile('sg-singapore', 'Darwin')['locale'], 'en_SG') + india = [r for r in profiles('Windows') if r['country'] == 'IN'] + self.assertTrue(any('Asia/Kolkata' in r['aliases'] and r['timezone'] == 'India Standard Time' for r in india)) + self.assertTrue(any(r['iana'] == 'Europe/Amsterdam' and r['locale'] == 'nl-NL' for r in profiles('Windows'))) + + def test_adding_region_choices_does_not_make_unsupported_actual_location_pass(self): + from envscope.regions import region_finding + for code in ('CN', 'RU'): + self.assertTrue(any(p['country'] == code for p in profiles('Windows'))) + # The separately maintained service policy remains authoritative. + self.assertEqual(region_finding([], {'usage_country': code, 'country': 'US'}).status.value, 'fail') diff --git a/tests/test_region_selection.py b/tests/test_region_selection.py index 3b2dbee..741b44f 100644 --- a/tests/test_region_selection.py +++ b/tests/test_region_selection.py @@ -199,6 +199,40 @@ def test_select_all_can_prepare_without_irrelevant_target_rows_blocking_it(self) self.assertTrue(value['manual_items']) self.assertFalse(self.writes) + def test_combined_plan_repairs_json_and_code_security_in_one_review(self): + ops = Operations(self.store, self.home, discoverer=lambda: [], environment_reader=lambda: self.inventory) + ops.config_remedies.environ = {} + self.report() + config = self.home / '.claude/settings.json' + original = b'{"env":{"NODE_TLS_REJECT_UNAUTHORIZED":"0"},"permissions":{"deny":["Read(.env)"]},}' + config.write_bytes(original) + report = self.report() + params = {'report_id': report['report_id'], 'check_ids': ['extra.config', 'extra.cli_env'], 'enhance_cli': True} + plan = ops.run('inspection-plan', params, report=report) + self.assertEqual(plan['state'], 'planned') + self.assertTrue(any('TLS' in effect for effect in plan['effects'])) + self.assertEqual(config.read_bytes(), original) + result = ops.run('inspection-apply', {'plan_id': plan['plan_id']}, confirmed=True, report=report) + self.assertEqual(result['state'], 'executed') + self.assertTrue(result['needs_retest']) + self.assertTrue(result['before']['configuration']) + content = json.loads(config.read_bytes()) + self.assertEqual(content['env']['NODE_TLS_REJECT_UNAUTHORIZED'], '1') + self.assertEqual(content['permissions'], {'deny': ['Read(.env)']}) + + def test_combined_code_enhancement_requires_measured_code_scope(self): + ops = Operations(self.store, self.home, discoverer=lambda: [], environment_reader=lambda: self.inventory) + report = self.report(['baseline.timezone']) + params = {'report_id': report['report_id'], 'check_ids': ['baseline.timezone'], 'enhance_cli': True} + with self.assertRaises(ValueError): + ops.run('inspection-plan', params, report=report) + with self.assertRaises(ValueError): + ops.run('inspection-plan', {**params, 'enhance_cli': 'true'}, report=report) + with self.assertRaises(ValueError): + ops.solutions.prepare(report, check_ids=['baseline.timezone'], enhance_cli=True) + self.assertEqual(ops.run('inspection-plan', {**params, 'check_ids': ['extra.cli_env']}, report=report)['state'], 'retest_required') + self.assertFalse(self.writes) + def test_pending_region_recovery_is_not_replaced_by_another_plan(self): report = self.report() plan = self.region.prepare(report, ['timezone'])