返回 CodeWhale
platform-selectors.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / platform-selectors.test.mjs
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
95 lines Plain Text