| 1 | import {test,after} from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import fs from 'node:fs'; |
| 4 | import os from 'node:os'; |
| 5 | import path from 'node:path'; |
| 6 | import {fileURLToPath} from 'node:url'; |
| 7 | import {spawnSync} from 'node:child_process'; |
| 8 | import {handle,closeAllSessions} from '../src/app-handler.mjs'; |
| 9 | import {create as linux} from '../src/backends/linux.mjs'; |
| 10 | import {calls} from './fixtures/selector-backend.mjs'; |
| 11 | |
| 12 | const fixtures=fileURLToPath(new URL('./fixtures/',import.meta.url)); |
| 13 | const saved={}; |
| 14 | for(const key of ['CODEWHALE_CU_TEST_BACKEND','CU_SELECTOR_PLATFORM','DISPLAY','WAYLAND_DISPLAY','XDG_SESSION_TYPE']) saved[key]=process.env[key]; |
| 15 | process.env.CODEWHALE_CU_TEST_BACKEND=path.join(fixtures,'selector-backend.mjs'); |
| 16 | after(async()=>{ |
| 17 | await closeAllSessions(); |
| 18 | for(const [key,value] of Object.entries(saved)) if(value===undefined)delete process.env[key];else process.env[key]=value; |
| 19 | }); |
| 20 | const invalidRefs=[null,{},[],false,0,'Fixture',{pid:123},{bundle_id:'test.fixture'},{name:''},{name:' '},{name:123},{name:'Fixture',pid:123},{unknown:'Fixture'}]; |
| 21 | for(const platform of ['linux','harmonyos']) { |
| 22 | test(`${platform}: real app-handler refuses unsupported selectors before any observation or input runner`,async()=>{ |
| 23 | process.env.CU_SELECTOR_PLATFORM=platform; |
| 24 | const call=(tool,args)=>handle({tool,args},{sessionId:`selectors-${platform}`}); |
| 25 | async function rejected(tool,args) { |
| 26 | const reply=await call(tool,args); |
| 27 | assert.equal(reply.ok,false,`${platform}/${tool} should refuse ${JSON.stringify(args)}`); |
| 28 | assert.equal(reply.error.code,'unsupported_selector',JSON.stringify(reply)); |
| 29 | assert.equal(calls.length,0,'refusal must precede even capability probes'); |
| 30 | } |
| 31 | for(const tool of ['list_windows','screenshot']) { |
| 32 | for(const app_ref of [...invalidRefs,{name:'Fixture'}]) await rejected(tool,{app_ref}); |
| 33 | for(const window_id of [null,0,1,'0']) await rejected(tool,{window_id}); |
| 34 | } |
| 35 | for(const app_ref of platform==='harmonyos'?[...invalidRefs,{name:'Fixture'}]:invalidRefs) { |
| 36 | await rejected('get_app_state',{app_ref}); |
| 37 | for(const tool of ['set_value','perform_action']) await rejected(tool,{target:{app_ref,path:[],index:0},value:'synthetic',action:'click'}); |
| 38 | if(platform==='linux') await rejected('resolve_element',{app_ref,path:[]}); |
| 39 | } |
| 40 | for(const window_id of [null,0,1,'0']) await rejected('get_app_state',{window_id}); |
| 41 | for(const selector of [{windowIndex:1},{window_id:0},{window_id:null}]) { |
| 42 | for(const tool of ['set_value','perform_action']) await rejected(tool,{target:{...selector,path:[],index:0},value:'synthetic',action:'click'}); |
| 43 | if(platform==='linux') await rejected('resolve_element',{...selector,path:[]}); |
| 44 | } |
| 45 | }); |
| 46 | } |
| 47 | |
| 48 | test('Linux production Python uses the same exact unique app-name selection for observation, resolution and semantic actions',{skip:spawnSync('python3',['--version']).status!==0},async t=>{ |
| 49 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-atspi-selector-')); |
| 50 | t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 51 | const actions=path.join(dir,'actions.jsonl'); |
| 52 | process.env.DISPLAY='fixture';delete process.env.WAYLAND_DISPLAY;process.env.XDG_SESSION_TYPE='x11'; |
| 53 | let duplicate=false, editMode="text"; |
| 54 | const backend=linux({exec:{have:async()=>true,run:async(cmd,args)=>{ |
| 55 | assert.equal(cmd,'python3','only generated Python may run in this fixture'); |
| 56 | assert.equal(args[0],'-c'); |
| 57 | const prefix=`import importlib.util, sys\nspec = importlib.util.spec_from_file_location("pyatspi", ${JSON.stringify(path.join(fixtures,'pyatspi.py'))})\nmodule = importlib.util.module_from_spec(spec)\nsys.modules["pyatspi"] = module\nspec.loader.exec_module(module)\n`; |
| 58 | const result=spawnSync('python3',['-I','-S','-B','-c',prefix+args[1],...args.slice(2)],{encoding:'utf8',env:{...process.env,CU_ATSPI_ACTIONS:actions,CU_ATSPI_DUPLICATE:duplicate?'1':'0',CU_ATSPI_EDIT_MODE:editMode}}); |
| 59 | assert.equal(result.status,0,result.stderr); |
| 60 | return {code:result.status,stdout:result.stdout,stderr:result.stderr}; |
| 61 | }}}); |
| 62 | const explicit={name:'fixture'}; |
| 63 | const state=await backend.get_app_state({app_ref:explicit}); |
| 64 | assert.equal(state.name,'Fixture');assert.equal(state.elements[0].label,'Fixture'); |
| 65 | assert.equal((await backend.resolve_element({app_ref:explicit,path:[]})).element.label,'Fixture'); |
| 66 | await backend.perform_action({target:{app_ref:explicit,path:[],windowIndex:0},action:'click'}); |
| 67 | assert.deepEqual(fs.readFileSync(actions,'utf8').trim().split('\n').map(JSON.parse),[{name:'Fixture',action:'click'}]); |
| 68 | assert.equal((await backend.get_app_state({})).name,'Fixture Extended','omission preserves existing default'); |
| 69 | for(const name of ['Fixt','missing','Fixture*']) { |
| 70 | await assert.rejects(backend.get_app_state({app_ref:{name}}),/application not found/); |
| 71 | assert.equal((await backend.resolve_element({app_ref:{name},path:[]})).found,false); |
| 72 | await assert.rejects(backend.perform_action({target:{app_ref:{name},path:[]},action:'click'}),/app_not_found/); |
| 73 | } |
| 74 | duplicate=true; |
| 75 | await assert.rejects(backend.get_app_state({app_ref:explicit}),/application not found/); |
| 76 | assert.equal((await backend.resolve_element({app_ref:explicit,path:[]})).found,false); |
| 77 | await assert.rejects(backend.perform_action({target:{app_ref:explicit,path:[]},action:'click'}),/app_not_found/); |
| 78 | assert.equal(fs.readFileSync(actions,'utf8').trim().split('\n').length,1,'missing or ambiguous app names must never send a synthetic action'); |
| 79 | duplicate=false; |
| 80 | const target={app_ref:explicit,path:[0],windowIndex:0}; |
| 81 | const edits=()=>fs.readFileSync(actions,'utf8').trim().split('\n').map(JSON.parse).filter(x=>'value' in x); |
| 82 | const text='Updated 漢字 🌊'; |
| 83 | assert.deepEqual(await backend.set_value({target,value:text}),{action_sent:true,strategy:'a11y',verified:true,after:text}); |
| 84 | editMode='numeric'; |
| 85 | assert.equal((await backend.set_value({target,value:12.5})).after,12.5); |
| 86 | assert.deepEqual(edits().map(x=>x.value),[text,12.5]); |
| 87 | for(const [mode,error,sends] of [['readonly','element_read_only',0],['disabled','element_disabled',0],['query-failed','interface_failed',0],['rejected','value_rejected',1],['mismatch','value_verification_failed',1]]) { |
| 88 | editMode=mode; |
| 89 | const before=edits().length; |
| 90 | await assert.rejects(backend.set_value({target,value:'rejected'}),new RegExp(error)); |
| 91 | assert.equal(edits().length-before,sends,`${mode}: never replay a failed or unverified edit`); |
| 92 | } |
| 93 | |
| 94 | }); |
| 95 |