fix(B02): show installed recommendation actions and remove duplicate navigation
This commit is contained in:
1 parent
9f1e583e23
commit
14a1354c95
7 files changed
+196
-25
No files matched your search
@@ -0,0 +1,110 @@
|
||||
/** Offline real React DOM regression. Not a browser layout/pixel or native Electron test.
|
||||
* Usage: YCODE_TEST_DOM_MODULE=/path/to/node_modules/jsdom node scripts/test-recommended-skills-dom.mjs
|
||||
* --build-only creates a browser fixture without loading jsdom or Electron. No dependency installation. */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
const require = createRequire(import.meta.url)
|
||||
const base = path.resolve('.tmp'); fs.mkdirSync(base, { recursive: true })
|
||||
const output = fs.mkdtempSync(path.join(base, 'recommended-skills-dom-'))
|
||||
const source = path.resolve('src/renderer/src/capabilities/CapabilityCenter.tsx')
|
||||
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';import{createRoot}from'react-dom/client';import SkillsPage from${JSON.stringify(source)};const root=createRoot(document.getElementById('root'));window.__mount=()=>root.render(<SkillsPage initialTab="skills" onBackToChat={()=>{}} cwd="/controlled/workspace" onUseSkill={s=>window.__uses.push(s)}/>);window.__unmount=()=>root.unmount();window.__mount();`)
|
||||
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], outfile: path.join(output, 'fixture.js'), bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', loader: { '.svg': 'dataurl', '.png': 'dataurl', '.jpg': 'dataurl', '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl' }, logLevel: 'error' })
|
||||
if (process.argv.includes('--build-only')) { console.log(JSON.stringify({ result: 'BUILD_ONLY', output, limits: 'Compiled real React/CSS only; no UI execution or visual verification.' })); process.exit(0) }
|
||||
let JSDOM
|
||||
try { ({ JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_MODULE || 'jsdom')) } catch { throw Error('Provide an already-installed jsdom through YCODE_TEST_DOM_MODULE; this test never installs dependencies.') }
|
||||
const rec = { id:'archify', name:'Archify', description:'Controlled recommendation', author:'fixture', license:'MIT', repository:'fixture/archify', version:'3', commit:'a'.repeat(40), packagePath:'archify.zip', sha256:'b'.repeat(64), expectedBytes:100, containsScripts:true }
|
||||
const current = { id:'user:rec', name:rec.name, description:rec.description, version:rec.version, path:'/controlled/archify', scope:'user', enabled:true, userInvocable:true, disableModelInvocation:false, source:'imported', userModified:false, origin:{recommendationId:rec.id, repository:rec.repository, commit:rec.commit, packagePath:rec.packagePath, archiveSha256:rec.sha256, version:rec.version, installedAt:1} }
|
||||
const checks = [], errors = []
|
||||
let installed=[], failEnable=false, failCatalog=false, catalog=[rec], changed=new Set(), progressListeners=new Set(), progress=null, calls=[], sequence=0, holdInstall=null
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { url:'https://fixture.invalid', runScripts:'outside-only', pretendToBeVisual:true })
|
||||
const w=dom.window, d=w.document
|
||||
w.addEventListener('error', e=>errors.push(e.message))
|
||||
w.__uses=[]
|
||||
w.fetch=()=>{ throw Error('Network is forbidden in this fixture') }
|
||||
w.matchMedia=()=>({ matches:false, addEventListener(){}, removeEventListener(){} })
|
||||
w.ResizeObserver=class{observe(){} unobserve(){} disconnect(){}}
|
||||
const notify=()=>{ for(const listener of changed) listener() }
|
||||
const emit=(phase, operationId='foreign')=>{progress={phase,operationId,recommendationId:rec.id,receivedBytes:0,totalBytes:100,updatedAt:++sequence};for(const listener of progressListeners)listener(progress)}
|
||||
w.ycode={skills:{
|
||||
list:async()=>installed, recommendations:async()=>{if(failCatalog)throw Error('controlled catalog failure'); return catalog},
|
||||
onChanged:fn=>{changed.add(fn);return()=>changed.delete(fn)}, onInstallProgress:fn=>{progressListeners.add(fn);return()=>progressListeners.delete(fn)}, installState:async()=>progress,
|
||||
read:async()=>({skill:current,content:'Controlled content',files:[]}),
|
||||
setEnabled:async(id,enabled)=>{calls.push(['enable',id]);if(failEnable)throw Error('controlled enable failure');installed=installed.map(s=>s.id===id?{...s,enabled}:s);notify()},
|
||||
installRecommended:async(id,options)=>{calls.push(['install',id,options]);emit('downloading',options.operationId);await new Promise(resolve=>{holdInstall=resolve});installed=[current];emit('installed',options.operationId);notify();return current},
|
||||
cancelInstall:async()=>({cancelled:true}),remove:async()=>{},export:async()=>null,importPath:async()=>null
|
||||
},resources:{openExternal:async()=>{throw Error('External navigation forbidden')}}}
|
||||
const pause=()=>new Promise(resolve=>setTimeout(resolve,15))
|
||||
const wait=async(fn,label='condition')=>{for(let i=0;i<150;i++){if(fn())return;await pause()}throw Error('Timed out: '+label)}
|
||||
const check=(label,value)=>{assert.ok(value,label); checks.push(label)}
|
||||
const card=()=>d.querySelector('.yc-skill-recommendation-card')
|
||||
const button=label=>d.querySelector(`[aria-label="${label}"]`)
|
||||
const click=async(el)=>{assert.ok(el,'click target exists');el.click();await pause()}
|
||||
const set=async(skills,state)=>{installed=skills;notify();await wait(()=>card()?.dataset.recommendationState===state,state);await pause()}
|
||||
const close=async()=>{if(button('关闭技能详情'))await click(button('关闭技能详情'))}
|
||||
try {
|
||||
w.eval(fs.readFileSync(path.join(output,'fixture.js'),'utf8'))
|
||||
await wait(()=>card(),'initial render')
|
||||
check('uninstalled uses add slot',!!card().querySelector('.sm-add-btn'))
|
||||
check('normal footer removed from DOM',!d.querySelector('.yc-skill-recommendation-footer')&&!d.body.textContent.includes('查看已安装技能'))
|
||||
await set([current],'current')
|
||||
check('current uses installed CardFrame',!!card().querySelector('.sm-card--installed'))
|
||||
check('current removes Plus/add DOM',!card().querySelector('.sm-add-btn'))
|
||||
check('current use occupies status slot exactly once',card().querySelectorAll('.sm-status .sm-use-btn').length===1&&!card().querySelector('.yc-skill-recommendation-meta button'))
|
||||
await click(card().querySelector('.sm-use-btn'))
|
||||
check('use invokes one draft callback without installation',w.__uses.length===1&&!calls.some(c=>c[0]==='install'))
|
||||
check('use does not open install detail',!d.querySelector('[role=dialog]'))
|
||||
check('top nav count reflects installed list',button('已安装技能').textContent.includes('(1)'))
|
||||
await click(button('已安装技能'));await wait(()=>d.querySelector('[data-skill-id="user:rec"]'))
|
||||
await click(button('返回专家中心'));await wait(()=>card())
|
||||
check('reopen retains current state',card().dataset.recommendationState==='current')
|
||||
await set([{...current,enabled:false}],'current');failEnable=true
|
||||
await click(card().querySelector('.sm-use-btn'));await wait(()=>d.body.textContent.includes('controlled enable failure'))
|
||||
check('enable failure prevents draft invocation',w.__uses.length===1)
|
||||
failEnable=false;await click(card().querySelector('.sm-use-btn'));await wait(()=>w.__uses.length===2)
|
||||
check('disabled skill enabled before callback',w.__uses.at(-1).enabled===true&&calls.filter(c=>c[0]==='enable').length===2)
|
||||
await set([{...current,userInvocable:false}],'current')
|
||||
check('non-invocable current cannot use or reinstall',card().querySelector('.sm-use-btn')?.disabled&&!card().querySelector('.sm-add-btn'))
|
||||
for(const [name,skill,state,label] of [
|
||||
['outdated',{...current,origin:{...current.origin,version:'2'}},'outdated','更新 Archify'],
|
||||
['modified',{...current,userModified:true},'modified','替换 Archify'],
|
||||
['broken',{...current,error:'bad document'},'outdated','修复 Archify'],
|
||||
['same-name local',{...current,origin:undefined,userModified:true},'conflict','替换 Archify'],
|
||||
['broken same-name local',{...current,origin:undefined,error:'bad local'},'conflict','替换 Archify'],
|
||||
['different source',{...current,origin:{...current.origin,repository:'other/source'}},'outdated','更新 Archify']
|
||||
]) {
|
||||
await set([skill],state);check(name+' installed identity distinction',!!card().querySelector('.sm-card--installed')===(state!=='conflict'));check(name+' not represented as first add',!card().querySelector('.sm-add-btn')&&!!button(label));const before=calls.length
|
||||
await click(button(label));check(name+' requires explicit confirmation',!!button('确认安装推荐技能')&&calls.length===before);await close()
|
||||
}
|
||||
await set([{...current,id:'workspace:rec',scope:'workspace'}],'none')
|
||||
check('workspace is not treated as managed current',!!card().querySelector('.sm-add-btn')&&!card().querySelector('.sm-card--installed'))
|
||||
await set([],'none');await click(button('安装 Archify'))
|
||||
const staleConfirm=button('确认安装推荐技能');const beforeExternal=calls.filter(c=>c[0]==='install').length
|
||||
await set([current],'current');staleConfirm.click();await pause()
|
||||
check('external current while confirming prevents duplicate installation',calls.filter(c=>c[0]==='install').length===beforeExternal);await close()
|
||||
await set([],'none');await click(button('安装 Archify'))
|
||||
check('first install confirms provenance and scripts',d.querySelector('[role=dialog]').textContent.includes('fixture/archify')&&d.querySelector('[role=dialog]').textContent.includes('不会执行仓库中的任何安装命令'))
|
||||
const confirm=button('确认安装推荐技能');confirm.click();confirm.click();await wait(()=>holdInstall)
|
||||
check('duplicate confirmation issues only one request',calls.filter(c=>c[0]==='install').length===1)
|
||||
check('real host progress rendered',d.querySelector('[data-install-phase="downloading"]'))
|
||||
holdInstall();await wait(()=>card()?.dataset.recommendationState==='current');await close()
|
||||
check('install success automatically switches action DOM',!card().querySelector('.sm-add-btn')&&!!card().querySelector('.sm-use-btn'))
|
||||
await set([],'none');check('external uninstall restores add',!!card().querySelector('.sm-add-btn'))
|
||||
emit('downloading');await wait(()=>d.querySelector('[data-foreign-install=true]'))
|
||||
check('external install blocks new add',card().querySelector('.sm-add-btn').disabled)
|
||||
emit('installed');await wait(()=>!d.querySelector('[data-foreign-install=true]'))
|
||||
check('external terminal progress clears block',!card().querySelector('.sm-add-btn').disabled)
|
||||
// Unmount/remount via existing navigation reloads catalog, without touching real storage.
|
||||
failCatalog=true;await click(button('已安装技能'));await click(button('返回专家中心'));await wait(()=>d.querySelector('.yc-skill-recommendation-unavailable'))
|
||||
check('unavailable has no duplicate installed entry',!d.body.textContent.includes('查看已安装技能')&&!!button('已安装技能'))
|
||||
failCatalog=false;await click(d.querySelector('.yc-skill-recommendation-unavailable button'));await wait(()=>card())
|
||||
check('catalog retry recovers in place',!!card())
|
||||
catalog=[];await click(button('已安装技能'));await click(button('返回专家中心'));await wait(()=>d.querySelector('.yc-skill-recommendation-unavailable'))
|
||||
check('empty catalog has no footer',!d.querySelector('.yc-skill-recommendation-footer')&&!d.body.textContent.includes('查看已安装技能'))
|
||||
for(const tab of ['SkillHub','套件']) {await click([...d.querySelectorAll('[role=tab]')].find(e=>e.textContent===tab));check(tab+' has only top installed navigation',!d.body.textContent.includes('查看已安装技能')&&!!button('已安装技能'))}
|
||||
check('no uncaught renderer errors',errors.length===0)
|
||||
const report={result:'PASS',checks,calls,errors,limits:'Real React in offline jsdom; callback boundary tested, not production composer dispatch. No native Chromium, computed layout, screenshot, theme/hover/focus pixels, model SDK, actual install or network.'}
|
||||
fs.writeFileSync(path.join(output,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:'PASS',checks:checks.length,output}))
|
||||
} catch(error) {fs.writeFileSync(path.join(output,'failure.json'),JSON.stringify({error:String(error),stack:error.stack,checks,errors,html:d.body.innerHTML},null,2));console.error('Evidence:',output);throw error}
|
||||
finally {w.__unmount?.();w.close()}
|
||||
@@ -17,7 +17,7 @@ if (!electron) {
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_RENDERER_URL
|
||||
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
|
||||
const child = spawn(electronModule, [path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
|
||||
const timer = setTimeout(() => child.kill(), 120000)
|
||||
process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
|
||||
} else {
|
||||
@@ -28,7 +28,7 @@ if (!electron) {
|
||||
const userData = path.join(output, 'data')
|
||||
fs.mkdirSync(userData, { recursive: true })
|
||||
app.setPath('userData', userData)
|
||||
const checks = [], errors = [], calls = [], screenshots = []
|
||||
const checks = [], errors = [], calls = [], screenshots = [], actionStyles = []
|
||||
|
||||
const recommendation = {
|
||||
id: 'archify',
|
||||
@@ -197,6 +197,29 @@ createRoot(document.getElementById('root')).render(
|
||||
screenshots.push(name + '.png')
|
||||
}
|
||||
|
||||
const verifyCurrentActionStyles = async theme => {
|
||||
const selector = '[data-recommendation-id=archify] .sm-use-btn'
|
||||
const inspect = () => read(`(()=>{const b=document.querySelector(${JSON.stringify(selector)});const s=getComputedStyle(b);return{width:s.width,height:s.height,background:s.backgroundColor,color:s.color,outline:s.outline,focused:document.activeElement===b,hover:b.matches(':hover')}})()`)
|
||||
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
||||
await read(`document.activeElement?.blur()`); await pause(200)
|
||||
const normal = await inspect()
|
||||
check(theme + ' use action default background is transparent', normal.background, value => value === 'rgba(0, 0, 0, 0)' || value === 'transparent')
|
||||
const point = await read(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
|
||||
win.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200)
|
||||
const hover = await inspect()
|
||||
check(theme + ' pointer enters use action', hover.hover, true)
|
||||
check(theme + ' hover has a distinct background', hover.background !== normal.background, true)
|
||||
await capture('installed-use-' + theme + '-hover')
|
||||
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
||||
win.webContents.sendInputEvent({type:'keyDown',keyCode:'Tab'});win.webContents.sendInputEvent({type:'keyUp',keyCode:'Tab'})
|
||||
await read(`document.querySelector(${JSON.stringify(selector)}).focus()`);await pause(200)
|
||||
const focus = await inspect()
|
||||
check(theme + ' use action remains focusable with stable 24px slot', focus.focused && focus.width === '24px' && focus.height === '24px', true)
|
||||
await capture('installed-use-' + theme + '-focus')
|
||||
actionStyles.push({theme,normal,hover,focus})
|
||||
await read(`document.activeElement?.blur()`)
|
||||
}
|
||||
|
||||
try {
|
||||
await win.loadFile(path.join(output, 'index.html'))
|
||||
await wait('!!document.querySelector(".yc-skills-page")')
|
||||
@@ -229,7 +252,11 @@ createRoot(document.getElementById('root')).render(
|
||||
check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
|
||||
check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current')
|
||||
check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
|
||||
check('the installed skill has no add button', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-add-btn")'), false)
|
||||
check('the installed skill uses the recovered installed card', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-card--installed")'), true)
|
||||
check('the installed action uses the original action slot', await read('!!document.querySelector("[data-recommendation-id=archify] .sm-status .sm-use-btn")'), true)
|
||||
check('no duplicate under-card use action remains', await read('!!document.querySelector(".yc-skill-recommendation-meta button")'), false)
|
||||
check('the duplicate footer is absent', await read('!!document.querySelector(".yc-skill-recommendation-footer")'), false)
|
||||
check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true)
|
||||
check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true)
|
||||
check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true)
|
||||
@@ -237,8 +264,13 @@ createRoot(document.getElementById('root')).render(
|
||||
await capture('installed-detail')
|
||||
await click('[role=dialog] .sm-skill-detail-close')
|
||||
|
||||
const installedActionStyle = await read(`(()=>{const b=document.querySelector('[data-recommendation-id=archify] .sm-use-btn');const s=getComputedStyle(b);const svg=b.querySelector('svg');return {width:s.width,height:s.height,svgWidth:getComputedStyle(svg).width,svgHeight:getComputedStyle(svg).height}})()`)
|
||||
check('installed action uses recovered 24px slot and 16px icon', installedActionStyle, {width:'24px',height:'24px',svgWidth:'16px',svgHeight:'16px'})
|
||||
await capture('installed-recommendation-light')
|
||||
await verifyCurrentActionStyles('light')
|
||||
|
||||
// The installed list stays reachable and the skill can be used.
|
||||
await click('.yc-skill-recommendation-footer button')
|
||||
await click('[aria-label="已安装技能"]')
|
||||
await wait('document.querySelectorAll(".sm-card").length===1')
|
||||
check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify')
|
||||
await click('[aria-label="返回专家中心"]')
|
||||
@@ -247,7 +279,7 @@ createRoot(document.getElementById('root')).render(
|
||||
// A local skill with the same name requires an explicit overwrite.
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`)
|
||||
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"')
|
||||
await click('[aria-label="安装 Archify"]')
|
||||
await click('[aria-label="替换 Archify"]')
|
||||
await wait('!!document.querySelector(".yc-skill-detail-shell")')
|
||||
check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true)
|
||||
check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true)
|
||||
@@ -295,8 +327,8 @@ createRoot(document.getElementById('root')).render(
|
||||
await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]')
|
||||
check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1)
|
||||
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`)
|
||||
await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")')
|
||||
await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]')
|
||||
await wait('document.querySelector("[data-recommendation-id=archify] .sm-use-btn")?.title.includes("启用")')
|
||||
await click('[data-recommendation-id=archify] .sm-use-btn')
|
||||
await wait('window.__qa.uses.length===2')
|
||||
check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true)
|
||||
await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])')
|
||||
@@ -308,9 +340,10 @@ createRoot(document.getElementById('root')).render(
|
||||
await read(`location.reload()`)
|
||||
await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")')
|
||||
check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
|
||||
check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true)
|
||||
check('the unavailable state keeps top installed navigation', await read('!!document.querySelector("[aria-label=已安装技能]")'), true)
|
||||
check('the unavailable state has no duplicate installed entry', await read('[...document.querySelectorAll(".yc-skill-recommendation-unavailable button")].some(e=>e.textContent.includes("查看已安装技能"))'), false)
|
||||
await read(`window.ycode.skills.qaFailRecommendations(false)`)
|
||||
await read(`location.reload()`)
|
||||
await click('.yc-skill-recommendation-unavailable button')
|
||||
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
|
||||
|
||||
// An install owned by another window blocks a new install here.
|
||||
@@ -354,6 +387,7 @@ createRoot(document.getElementById('root')).render(
|
||||
check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true)
|
||||
check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true)
|
||||
await capture('recommendation-dark')
|
||||
await verifyCurrentActionStyles('dark')
|
||||
win.setSize(720, 900)
|
||||
await pause(400)
|
||||
check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true)
|
||||
@@ -365,7 +399,7 @@ createRoot(document.getElementById('root')).render(
|
||||
|
||||
check('no renderer errors', errors, [])
|
||||
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
|
||||
result: 'PASS', checks, calls, screenshots,
|
||||
result: 'PASS', checks, calls, screenshots, actionStyles,
|
||||
limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.'
|
||||
}, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
|
||||
|
||||
Reference in new issue
Block a user