//! Dynamic skill loading for web automation.
//!
//! Core types (`Skill`, `SkillTrigger`, `SkillRegistry`) are re-exported from
//! [`spider_skills`]. This module adds spider_agent-specific built-in skills
//! (with `pre_evaluate` JS) and S3 loading.
//!
//! # Example
//! ```rust
//! use spider_agent::automation::skills::{SkillRegistry, Skill, SkillTrigger};
//!
//! let mut registry = SkillRegistry::new();
//!
//! // Add a custom skill
//! registry.add(Skill::new(
//! "grid-selection",
//! "Image grid selection challenge solver",
//! )
//! .with_trigger(SkillTrigger::title_contains("select all"))
//! .with_trigger(SkillTrigger::html_contains("grid-item"))
//! .with_content("For image grids, identify all matching tiles and click them..."));
//!
//! // Match skills against current page state
//! let context = registry.match_context("https://example.com", "Select all stop signs", "<div class='grid-item'>...");
//! // context contains the skill prompt to inject into system_prompt_extra
//! ```
// Re-export core types from spider_skills
pub use spider_skills::{Skill, SkillRegistry, SkillTrigger};
// ─── S3 skill loading types ────────────────────────────────────────────────
/// Configuration for loading skills from an S3-compatible bucket.
#[cfg(feature = "skills_s3")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct S3SkillSource {
/// S3 bucket name.
pub bucket: String,
/// Folder prefix within the bucket, e.g. "tactics/".
#[serde(default)]
pub prefix: String,
/// AWS region override. Defaults to SDK environment resolution.
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
/// Custom endpoint URL for S3-compatible stores (MinIO, R2, etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_url: Option<String>,
/// File extensions to load. Defaults to `["md"]`.
#[serde(default = "default_s3_extensions")]
pub extensions: Vec<String>,
}
#[cfg(feature = "skills_s3")]
fn default_s3_extensions() -> Vec<String> {
vec!["md".to_string()]
}
#[cfg(feature = "skills_s3")]
impl S3SkillSource {
/// Create a new S3 skill source with bucket and prefix.
pub fn new(bucket: impl Into<String>, prefix: impl Into<String>) -> Self {
Self {
bucket: bucket.into(),
prefix: prefix.into(),
region: None,
endpoint_url: None,
extensions: default_s3_extensions(),
}
}
/// Set the AWS region.
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
/// Set a custom endpoint URL (for MinIO, R2, etc.).
pub fn with_endpoint_url(mut self, url: impl Into<String>) -> Self {
self.endpoint_url = Some(url.into());
self
}
/// Set file extensions to load (e.g. `["md", "json"]`).
pub fn with_extensions(mut self, exts: Vec<String>) -> Self {
self.extensions = exts;
self
}
}
/// Errors from S3 skill loading.
#[cfg(feature = "skills_s3")]
#[derive(Debug)]
pub enum S3SkillError {
/// AWS SDK error.
Aws(String),
/// No skills found in the specified bucket/prefix.
NoSkillsFound,
/// Failed to parse a skill file.
ParseError {
/// S3 object key that failed to parse.
key: String,
/// Reason for the parse failure.
reason: String,
},
}
#[cfg(feature = "skills_s3")]
impl std::fmt::Display for S3SkillError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Aws(e) => write!(f, "AWS S3 error: {}", e),
Self::NoSkillsFound => write!(f, "no skills found in S3 bucket"),
Self::ParseError { key, reason } => {
write!(f, "failed to parse skill '{}': {}", key, reason)
}
}
}
}
#[cfg(feature = "skills_s3")]
impl std::error::Error for S3SkillError {}
// ─── Built-in web challenge skills ──────────────────────────────────────────
/// Create a registry pre-loaded with built-in web challenge skills.
///
/// Loads all skills from `spider_skills::web_challenges::registry()` and then
/// adds engine-specific `pre_evaluate` JavaScript for skills that need it.
/// Pre-evaluate JS runs in the browser before the LLM sees the page,
/// extracting puzzle state automatically.
pub fn builtin_web_challenges() -> SkillRegistry {
// Load all skills from the spider_skills crate (110 skills)
let mut registry = spider_skills::web_challenges::registry();
// Add pre_evaluate JS overlays for skills that need engine-side computation.
// These run before the LLM inference round to extract puzzle state into document.title.
if let Some(skill) = registry.get_mut("rotation-puzzle") {
skill.pre_evaluate = Some(ROTATION_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("tic-tac-toe") {
skill.pre_evaluate = Some(TTT_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("word-search") {
skill.pre_evaluate = Some(WORD_SEARCH_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("nested-grid") {
skill.pre_evaluate = Some(NESTED_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("whack-a-mole") {
skill.pre_evaluate = Some(WHACK_A_MOLE_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("draw-circle") {
skill.pre_evaluate = Some(DRAW_CIRCLE_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("sliding-puzzle") {
skill.pre_evaluate = Some(SLIDING_PUZZLE_PRE_EVALUATE_JS.to_string());
}
if let Some(skill) = registry.get_mut("math-solver") {
skill.pre_evaluate = Some(MATH_PRE_EVALUATE_JS.to_string());
}
registry
}
// ─── S3 skill loading ───────────────────────────────────────────────────────
/// Load skills from an S3-compatible bucket into an existing registry.
///
/// Name conflicts: S3 skills replace any existing skill with the same name.
/// Returns the count of successfully loaded skills.
#[cfg(feature = "skills_s3")]
pub async fn load_from_s3(
registry: &mut SkillRegistry,
source: &S3SkillSource,
) -> Result<usize, S3SkillError> {
let sdk_config = {
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(ref region) = source.region {
loader = loader.region(aws_config::Region::new(region.clone()));
}
if let Some(ref endpoint) = source.endpoint_url {
loader = loader.endpoint_url(endpoint);
}
loader.load().await
};
let client = aws_sdk_s3::Client::new(&sdk_config);
let mut continuation_token: Option<String> = None;
let mut loaded = 0usize;
let exts: Vec<&str> = source.extensions.iter().map(|s| s.as_str()).collect();
loop {
let mut req = client
.list_objects_v2()
.bucket(&source.bucket)
.prefix(&source.prefix);
if let Some(ref token) = continuation_token {
req = req.continuation_token(token);
}
let resp = req
.send()
.await
.map_err(|e| S3SkillError::Aws(e.to_string()))?;
let contents = resp.contents();
{
for obj in contents {
let key = match obj.key() {
Some(k) => k,
None => continue,
};
// Filter by extension
let matches_ext = exts.iter().any(|ext| key.ends_with(&format!(".{}", ext)));
if !matches_ext {
continue;
}
// Download the object
let get_resp = client
.get_object()
.bucket(&source.bucket)
.key(key)
.send()
.await
.map_err(|e| S3SkillError::Aws(e.to_string()))?;
let body = get_resp
.body
.collect()
.await
.map_err(|e| S3SkillError::Aws(e.to_string()))?;
let text = String::from_utf8_lossy(&body.into_bytes()).into_owned();
// Parse based on extension
let skill = if key.ends_with(".json") {
serde_json::from_str::<Skill>(&text).map_err(|e| S3SkillError::ParseError {
key: key.to_string(),
reason: e.to_string(),
})?
} else {
// Markdown
Skill::from_markdown(&text).ok_or_else(|| S3SkillError::ParseError {
key: key.to_string(),
reason: "invalid markdown frontmatter".to_string(),
})?
};
// Replace existing skill with same name
registry.remove(&skill.name);
registry.add(skill);
loaded += 1;
}
}
if resp.is_truncated() == Some(true) {
continuation_token = resp.next_continuation_token().map(|s| s.to_string());
} else {
break;
}
}
if loaded == 0 {
return Err(S3SkillError::NoSkillsFound);
}
Ok(loaded)
}
/// Create a registry with built-in web challenge skills merged with S3 skills.
///
/// S3 skills override built-in skills with the same name.
#[cfg(feature = "skills_s3")]
pub async fn with_builtin_and_s3(source: &S3SkillSource) -> Result<SkillRegistry, S3SkillError> {
let mut registry = builtin_web_challenges();
load_from_s3(&mut registry, source).await?;
Ok(registry)
}
// ─── Pre-evaluate JS constants ──────────────────────────────────────────
//
// These JS snippets run in the browser BEFORE the LLM sees the page.
// They extract puzzle state into document.title so the model can read it.
// Skill content lives in spider_skills crate (.md files via include_str!).
/// JS executed by the engine before the LLM sees the rotation page.
/// Reads each tile's CSS transform, computes clicks needed, writes to title.
/// Does NOT click — the model uses real Click actions based on the title info.
const ROTATION_PRE_EVALUATE_JS: &str = "try{const t=[...document.querySelectorAll('.rotating-item')];const n=t.length;const tiles=t.map((e,i)=>{const m=getComputedStyle(e).transform;let c=0;if(m&&m!=='none'){const v=m.match(/matrix\\(([^)]+)\\)/);if(v){const p=v[1].split(',').map(Number);const a=Math.round(Math.atan2(p[1],p[0])*180/Math.PI);c=a>45&&a<135?3:Math.abs(a)>135?2:a<-45&&a>-135?1:0;}}return{i,c};});const done=tiles.every(t=>t.c===0);const clicks=tiles.filter(t=>t.c>0).map(t=>t.i+':'+t.c).join(',');document.title='ROT:'+JSON.stringify({n,done,clicks});}catch(e){document.title='ROT_ERR:'+e.message;}";
/// JS executed by the engine before the LLM sees the TTT page.
/// Strategy: win → block → fork (create 2+ threats) → block fork → center → opposite corner → corner → side.
const TTT_PRE_EVALUATE_JS: &str = r##"try{const cells=[...document.querySelectorAll('.grid-item')].filter(el=>el.offsetWidth>20&&el.offsetHeight>20);const n=cells.length;const occ=new Set();cells.forEach((el,i)=>{const inner=el.querySelector('.tic-tac-toe-cell');if(inner&&(inner.className.includes('cell-selected')||inner.className.includes('cell-disabled')))occ.add(i);});let tr=document.getElementById('ttt-h');if(!tr){tr=document.createElement('div');tr.id='ttt-h';tr.style.display='none';tr.dataset.m='';document.body.appendChild(tr);}const my=new Set(tr.dataset.m?tr.dataset.m.split(',').map(Number):[]);if(occ.size===0)my.clear();for(const m of my)if(!occ.has(m))my.delete(m);const opp=new Set([...occ].filter(i=>!my.has(i)));const board=Array.from({length:9},(_,i)=>my.has(i)?'M':opp.has(i)?'T':'.');const W=[[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];let myWin=false,thWin=false;for(const w of W){if(w.every(i=>board[i]==='M'))myWin=true;if(w.every(i=>board[i]==='T'))thWin=true;}
function countThreats(b,mark){let t=0;for(const w of W){const m=w.filter(i=>b[i]===mark),e=w.filter(i=>b[i]==='.');if(m.length===2&&e.length===1)t++;}return t;}
let best=-1;if(!myWin&&!thWin){
for(const w of W){const mi=w.filter(i=>board[i]==='M'),e=w.filter(i=>board[i]==='.');if(mi.length===2&&e.length===1){best=e[0];break;}}
if(best<0)for(const w of W){const ti=w.filter(i=>board[i]==='T'),e=w.filter(i=>board[i]==='.');if(ti.length===2&&e.length===1){best=e[0];break;}}
if(best<0){let bestFork=-1,bestScore=0;for(let i=0;i<9;i++){if(board[i]!=='.')continue;const b2=[...board];b2[i]='M';const threats=countThreats(b2,'M');if(threats>=2&&threats>bestScore){bestScore=threats;bestFork=i;}}best=bestFork;}
if(best<0){for(let i=0;i<9;i++){if(board[i]!=='.')continue;const b2=[...board];b2[i]='T';if(countThreats(b2,'T')>=2){best=i;break;}}}
if(best<0&&board[4]==='.')best=4;
if(best<0){const opc=[[0,8],[2,6],[6,2],[8,0]];for(const[c,o]of opc)if(board[c]==='T'&&board[o]==='.')best=o;}
if(best<0)for(const c of[0,2,6,8])if(board[c]==='.'){best=c;break;}
if(best<0)for(const c of[1,3,5,7])if(board[c]==='.'){best=c;break;}
}let clicked=false;if(best>=0&&cells[best]){const el=cells[best];const r=el.getBoundingClientRect();const ev={bubbles:true,cancelable:true,clientX:r.x+r.width/2,clientY:r.y+r.height/2};el.dispatchEvent(new PointerEvent('pointerdown',ev));el.dispatchEvent(new MouseEvent('mousedown',ev));el.dispatchEvent(new PointerEvent('pointerup',ev));el.dispatchEvent(new MouseEvent('mouseup',ev));el.dispatchEvent(new MouseEvent('click',ev));clicked=true;my.add(best);board[best]='M';for(const w of W)if(w.every(i=>board[i]==='M'))myWin=true;}tr.dataset.m=[...my].join(',');const full=!board.includes('.');document.title='TTT:'+JSON.stringify({n,board:board.join(''),best,clicked,myWin,thWin,full});}catch(e){document.title='TTT_ERR:'+e.message;}
"##;
/// JS executed by the engine before the LLM sees the Word Search page.
/// Extracts grid, finds words, outputs coordinates for engine-side CDP drag.
const WORD_SEARCH_PRE_EVALUATE_JS: &str = r##"try{
const doneEl=document.getElementById('ws-engine-done');
if(doneEl){document.title=doneEl.dataset.t;throw'done';}
let cells=[...document.querySelectorAll('.word-search-grid-item')];
if(!cells.length)cells=[...document.querySelectorAll('.grid-item')].filter(el=>el.textContent.trim().length===1);
if(!cells.length)cells=[...document.querySelectorAll('[class*=letter]')].filter(el=>el.textContent.trim().length===1);
const n=cells.length;
if(n<4){document.title='WS:'+JSON.stringify({n,err:'no_grid'});}
else{
const rects=cells.map(c=>{const r=c.getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};});
const letters=cells.map(c=>c.textContent.trim().toUpperCase());
const tops=[...new Set(rects.map(r=>r.y))].sort((a,b)=>a-b);
const rows=tops.length||1,cols=Math.round(n/rows)||1;
const grid=[];for(let r=0;r<rows;r++)grid.push(letters.slice(r*cols,(r+1)*cols));
const dirs=[[0,1],[0,-1],[1,0],[-1,0],[1,1],[1,-1],[-1,1],[-1,-1]];
function findWord(w){for(let r=0;r<rows;r++)for(let c=0;c<cols;c++)for(const[dr,dc]of dirs){let ok=true;for(let k=0;k<w.length;k++){const nr=r+dr*k,nc=c+dc*k;if(nr<0||nr>=rows||nc<0||nc>=cols||grid[nr][nc]!==w[k]){ok=false;break;}}if(ok){const pts=[];for(let k=0;k<w.length;k++){const idx=(r+dr*k)*cols+(c+dc*k);pts.push(rects[idx]);}return pts;}}return null;}
let words=[];
let wordEls=[...document.querySelectorAll('.word-search-words span,.word-search-word')];
if(!wordEls.length)wordEls=[...document.querySelectorAll('[class*=word-item],[class*=clue]')];
if(!wordEls.length)wordEls=[...document.querySelectorAll('.word-search-words li,.words li')];
if(!wordEls.length)wordEls=[...document.querySelectorAll('.words span')];
words=wordEls.map(el=>el.textContent.trim().toUpperCase().replace(/\s+/g,'')).filter(w=>w.length>1&&w.length<=20&&w.match(/^[A-Z]+$/));
if(!words.length){
const txts=[...document.querySelectorAll('h1,h2,h3,h4,p,span,div,label')];
for(const el of txts){
const t=el.textContent.trim();
if(t.length>200||t.length<8)continue;
const m=t.match(/(?:select|find|choose|highlight)\s+(?:all\s+)?(?:the\s+)?(?:squares?\s+)?(?:with|containing|that\s+have|showing|of)\s+(?:a\s+|an?\s+)?(.+)/i);
if(m){
const raw=m[1].replace(/[.!?]+$/,'').split(/\s+and\s+|,\s*/i).map(s=>s.trim());
words=raw.map(s=>s.toUpperCase().replace(/[^A-Z]/g,'')).filter(w=>w.length>=2);
if(words.length)break;
}
}
}
const found={};
words.forEach(w=>{
const pts=findWord(w);
if(pts){found[w]=pts;return;}
for(let i=2;i<=w.length-2;i++){const p1=w.slice(0,i),p2=w.slice(i);const r1=findWord(p1),r2=findWord(p2);if(r1&&r2){found[p1]=r1;found[p2]=r2;return;}}
});
const fk=Object.keys(found);
if(fk.length>0){document.title='WS_DRAG:'+JSON.stringify({n,words:fk,drags:fk.map(w=>found[w])});}
else{const gt=grid.map(r=>r.join('')).join('/');document.title='WS:'+JSON.stringify({n,rows,cols,grid:gt,words,found:{}});}
}}catch(e){if(e!=='done'){document.title='WS_ERR:'+(e&&e.message||String(e));}}
"##;
/// JS executed by the engine before the LLM sees the nested grid page.
/// Detects stop sign image, computes bounding-rect overlap with leaf boxes,
/// and reports JSON with toClick array for engine auto-clicking.
const NESTED_PRE_EVALUATE_JS: &str = r##"try{
var done=document.getElementById('nest-engine-done');
if(done){document.title=done.dataset.t;throw'done';}
var all=[...document.querySelectorAll('.box')];
var leaf=all.filter(function(b){return !b.querySelector('.box')&&!b.querySelector('.nested-container');});
leaf.forEach(function(b,i){b.setAttribute('data-spider-id',String(i));});
var ssRect=null;
var imgs=[...document.querySelectorAll('img')].filter(function(el){var r=el.getBoundingClientRect();return r.width>20&&r.height>20;});
if(imgs.length>0){var best=imgs[0];for(var im of imgs){var rr=im.getBoundingClientRect();if(rr.width*rr.height>best.getBoundingClientRect().width*best.getBoundingClientRect().height)best=im;}ssRect=best.getBoundingClientRect();}
if(!ssRect){var cands=[...document.querySelectorAll('[class*=sign],[class*=stop],[class*=image],[class*=captcha-image]')].filter(function(el){var r=el.getBoundingClientRect();return r.width>20&&r.height>20;});if(cands.length>0)ssRect=cands[0].getBoundingClientRect();}
if(!ssRect){var bgEls=[...document.querySelectorAll('.nested-container,div')].filter(function(el){var bg=getComputedStyle(el).backgroundImage;return bg&&bg!=='none';});if(bgEls.length>0)ssRect=bgEls[0].getBoundingClientRect();}
var toClick=[];var selected=[];
var boxes=leaf.map(function(b,i){var r=b.getBoundingClientRect();var sel=b.classList.contains('selected');if(sel){selected.push(i);}else if(ssRect){var ox=Math.max(0,Math.min(r.right,ssRect.right)-Math.max(r.left,ssRect.left));var oy=Math.max(0,Math.min(r.bottom,ssRect.bottom)-Math.max(r.top,ssRect.top));if(ox>2&&oy>2)toClick.push(i);}
return{id:i,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),w:Math.round(r.width),h:Math.round(r.height)};});
document.title='NEST:'+JSON.stringify({total:leaf.length,sel:selected.length,toClick:toClick,selected:selected,hasSign:!!ssRect,boxes:boxes});
}catch(e){if(e!=='done')document.title='NEST_ERR:'+e.message;}
"##;
/// JS executed by the engine for whack-a-mole — detects and clicks visible moles.
const WHACK_A_MOLE_PRE_EVALUATE_JS: &str = r##"try{
var done=document.getElementById('wam-engine-done');
if(done){document.title=done.dataset.t;throw'done';}
var moles=[...document.querySelectorAll('.grid-item,.mole,.whack-target,[class*=mole]')].filter(el=>{
var r=el.getBoundingClientRect();
if(r.width<10||r.height<10)return false;
var s=getComputedStyle(el);
if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;
var img=el.querySelector('img');
if(img){var src=img.src||img.dataset.src||'';if(src.includes('mole')||src.includes('gopher'))return true;}
var bg=s.backgroundImage||'';
if(bg.includes('mole')||bg.includes('gopher'))return true;
var cls=(el.className||'').toLowerCase();
if(cls.includes('mole')||cls.includes('active')||cls.includes('visible'))return true;
return false;
});
var clicked=0;
moles.forEach(function(el){
var r=el.getBoundingClientRect();
var ev={bubbles:true,cancelable:true,clientX:r.x+r.width/2,clientY:r.y+r.height/2};
el.dispatchEvent(new PointerEvent('pointerdown',ev));
el.dispatchEvent(new MouseEvent('mousedown',ev));
el.dispatchEvent(new PointerEvent('pointerup',ev));
el.dispatchEvent(new MouseEvent('mouseup',ev));
el.dispatchEvent(new MouseEvent('click',ev));
clicked++;
});
document.title='WAM:'+JSON.stringify({moles:moles.length,clicked:clicked});
}catch(e){if(e!=='done')document.title='WAM_ERR:'+(e&&e.message||String(e));}
"##;
/// JS that computes a circular mouse path and reports center/radius for engine drag.
const DRAW_CIRCLE_PRE_EVALUATE_JS: &str = r##"try{
var done=document.getElementById('circle-engine-done');
if(done){document.title=done.dataset.t;throw'done';}
var canvas=document.querySelector('canvas');
if(!canvas){var divs=[...document.querySelectorAll('div')].filter(function(d){var r=d.getBoundingClientRect();return r.width>100&&r.height>100&&r.width<800;});canvas=divs[0];}
if(canvas){var r=canvas.getBoundingClientRect();var cx=Math.round(r.x+r.width/2);var cy=Math.round(r.y+r.height/2);var rad=Math.round(Math.min(r.width,r.height)*0.35);var pts=[];for(var i=0;i<=36;i++){var a=i*Math.PI*2/36;pts.push({x:Math.round(cx+rad*Math.cos(a)),y:Math.round(cy+rad*Math.sin(a))});}
document.title='CIRCLE:'+JSON.stringify({cx:cx,cy:cy,rad:rad,pts:pts});}
else{document.title='CIRCLE_ERR:no_canvas';}
}catch(e){if(e!=='done')document.title='CIRCLE_ERR:'+e.message;}
"##;
/// JS that reads sliding puzzle tile positions and computes moves.
const SLIDING_PUZZLE_PRE_EVALUATE_JS: &str = r##"try{
var tiles=[...document.querySelectorAll('[class*=tile],[class*=slide],[class*=puzzle] > div')].filter(function(t){var r=t.getBoundingClientRect();return r.width>20&&r.height>20;});
if(tiles.length>0){var info=tiles.map(function(t,i){var r=t.getBoundingClientRect();var txt=(t.textContent||'').trim().substring(0,5);return{id:i,x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height),txt:txt,cls:t.className.substring(0,30)};});
document.title='SLIDE:'+JSON.stringify({n:tiles.length,tiles:info});}
else{document.title='SLIDE_ERR:no_tiles';}
}catch(e){document.title='SLIDE_ERR:'+e.message;}
"##;
/// JS that reads a math expression from the page and computes the answer.
const MATH_PRE_EVALUATE_JS: &str = r##"try{
var expr='';var els=[...document.querySelectorAll('h1,h2,h3,.math,.equation,.captcha-text,.challenge-text,p,span')];
for(var el of els){var t=(el.textContent||'').trim();if(/[\d\+\-\*\/\=\^]+/.test(t)&&t.length>2&&t.length<100){expr=t;break;}}
if(expr){try{var clean=expr.replace(/[^0-9\+\-\*\/\.\(\)\^%]/g,' ').trim().replace(/\^/g,'**');var ans=Function('"use strict";return ('+clean+')')();document.title='MATH:'+JSON.stringify({expr:expr,answer:String(ans)});}catch(ee){document.title='MATH:'+JSON.stringify({expr:expr,answer:''});}}
else{document.title='MATH_ERR:no_expression';}
}catch(e){document.title='MATH_ERR:'+e.message;}
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_skill_trigger_matching() {
let skill = Skill::new("grid-selection", "Select images from grid")
.with_trigger(SkillTrigger::title_contains("select all"))
.with_trigger(SkillTrigger::html_contains("grid-item"))
.with_content("Select tiles matching the description...");
// Title match
assert!(skill.matches("https://example.com", "Select all stop signs", ""));
// HTML match
assert!(skill.matches("https://example.com", "", "<div class='grid-item'>"));
// No match
assert!(!skill.matches("https://example.com", "Home page", "<div>hello</div>"));
}
#[test]
fn test_skill_registry_matching() {
let mut registry = SkillRegistry::new();
registry.add(
Skill::new("grid", "Grid challenges")
.with_trigger(SkillTrigger::html_contains("grid-item"))
.with_content("Grid strategy here")
.with_priority(1),
);
registry.add(
Skill::new("ttt", "Tic-tac-toe")
.with_trigger(SkillTrigger::title_contains("xoxo"))
.with_content("TTT strategy here")
.with_priority(2),
);
// Only grid matches
let ctx = registry.match_context("", "", "<div class='grid-item'>");
assert!(ctx.contains("Grid strategy"));
assert!(!ctx.contains("TTT strategy"));
// Only TTT matches
let ctx = registry.match_context("", "XOXO Game", "<div>board</div>");
assert!(ctx.contains("TTT strategy"));
assert!(!ctx.contains("Grid strategy"));
// Neither matches
let ctx = registry.match_context("", "Home", "<div>hello</div>");
assert!(ctx.is_empty());
}
#[test]
fn test_skill_from_markdown() {
let md = r#"---
name: word-search
description: Word search grid solver
priority: 5
triggers:
- title_contains: word search
- html_contains: grid-item
---
# Word Search Strategy
Find words in the grid by extracting letters and searching algorithmically.
Use Evaluate to click found cells programmatically."#;
let skill = Skill::from_markdown(md).unwrap();
assert_eq!(skill.name, "word-search");
assert_eq!(skill.description, "Word search grid solver");
assert_eq!(skill.priority, 5);
assert_eq!(skill.triggers.len(), 2);
assert!(skill.content.contains("Word Search Strategy"));
assert!(skill.content.contains("algorithmically"));
}
#[test]
fn test_skill_no_triggers_never_matches() {
let skill = Skill::new("empty", "No triggers").with_content("content");
assert!(!skill.matches("", "", ""));
}
#[test]
fn test_always_trigger() {
let skill = Skill::new("always", "Always active")
.with_trigger(SkillTrigger::Always)
.with_content("Always injected");
assert!(skill.matches("", "", ""));
assert!(skill.matches("any", "any", "any"));
}
#[test]
fn test_builtin_web_challenges() {
let registry = builtin_web_challenges();
assert!(registry.len() >= 110);
// Image grid should match on grid-item class
let ctx = registry.match_context("", "", "<div class='grid-item'>img</div>");
assert!(ctx.contains("image-grid-selection"));
// Tic-tac-toe should match on XOXO title
let ctx = registry.match_context("", "XOXO Game", "");
assert!(ctx.contains("tic-tac-toe"));
// Word search should match on title
let ctx = registry.match_context("", "Word Search Puzzle", "");
assert!(ctx.contains("word-search"));
// Rotation should match on rotating-item class
let ctx = registry.match_context("", "", "<div class='rotating-item'>");
assert!(ctx.contains("rotation-puzzle"));
// No match on unrelated page
let ctx = registry.match_context("https://example.com", "Home", "<div>hello</div>");
assert!(ctx.is_empty());
}
#[test]
fn test_pre_evaluate_field() {
// Default is None
let skill = Skill::new("test", "test skill");
assert!(skill.pre_evaluate.is_none());
// Builder sets it
let skill = Skill::new("test", "test skill").with_pre_evaluate("document.title='HELLO'");
assert_eq!(
skill.pre_evaluate.as_deref(),
Some("document.title='HELLO'")
);
// from_markdown leaves it None
let md = "---\nname: foo\n---\ncontent";
let skill = Skill::from_markdown(md).unwrap();
assert!(skill.pre_evaluate.is_none());
}
#[test]
fn test_find_pre_evaluates() {
let mut registry = SkillRegistry::new();
// Skill with pre_evaluate
registry.add(
Skill::new("solver", "Auto-solver")
.with_trigger(SkillTrigger::title_contains("game"))
.with_pre_evaluate("document.title='SOLVED'")
.with_content("Read the title.")
.with_priority(5),
);
// Skill without pre_evaluate
registry.add(
Skill::new("helper", "Manual helper")
.with_trigger(SkillTrigger::title_contains("game"))
.with_content("Use Evaluate to read...")
.with_priority(3),
);
// Both match, but only one has pre_evaluate
let pre_evals = registry.find_pre_evaluates("", "game level", "");
assert_eq!(pre_evals.len(), 1);
assert_eq!(pre_evals[0].0, "solver");
assert_eq!(pre_evals[0].1, "document.title='SOLVED'");
// No match
let pre_evals = registry.find_pre_evaluates("", "home", "");
assert!(pre_evals.is_empty());
}
#[test]
fn test_builtin_ttt_has_pre_evaluate() {
let registry = builtin_web_challenges();
let ttt = registry
.get("tic-tac-toe")
.expect("tic-tac-toe skill missing");
assert!(
ttt.pre_evaluate.is_some(),
"TTT should have pre_evaluate JS"
);
let js = ttt.pre_evaluate.as_deref().unwrap();
assert!(
js.contains("TTT:"),
"TTT pre_evaluate should set title with TTT: prefix"
);
assert!(
js.contains("cell-selected"),
"TTT pre_evaluate should use cell-selected selector"
);
assert!(
js.contains("cell-disabled"),
"TTT pre_evaluate should use cell-disabled selector"
);
assert!(
js.contains("TTT_ERR"),
"TTT pre_evaluate should have error handling"
);
}
#[test]
fn test_builtin_word_search_has_pre_evaluate() {
let registry = builtin_web_challenges();
let ws = registry
.get("word-search")
.expect("word-search skill missing");
assert!(
ws.pre_evaluate.is_some(),
"Word search should have pre_evaluate JS"
);
let js = ws.pre_evaluate.as_deref().unwrap();
assert!(
js.contains("WS:"),
"WS pre_evaluate should set title with WS: prefix"
);
assert!(
js.contains("word-search-grid-item"),
"WS pre_evaluate should use word-search-grid-item selector"
);
assert!(
js.contains("WS_ERR"),
"WS pre_evaluate should have error handling"
);
}
#[test]
fn test_builtin_ttt_triggers_fixed() {
let registry = builtin_web_challenges();
let ttt = registry.get("tic-tac-toe").unwrap();
// Should match on cell-selected (the correct DOM class)
assert!(ttt.matches("", "", "<div class='cell-selected'>"));
// Should match on cell-disabled (the correct DOM class)
assert!(ttt.matches("", "", "<div class='cell-disabled'>"));
// Should match on XOXO title
assert!(ttt.matches("", "XOXO", ""));
}
#[test]
fn test_simplified_skills_no_evaluate_js() {
let registry = builtin_web_challenges();
// TTT simplified content should NOT contain Evaluate JS
let ttt = registry.get("tic-tac-toe").unwrap();
assert!(ttt.content.contains("Do NOT write any Evaluate JS"));
assert!(!ttt.content.contains("querySelectorAll"));
// Word search is self-solving via pre_evaluate
let ws = registry.get("word-search").unwrap();
assert!(
ws.content.contains("auto-dragged"),
"WS skill should mention auto-drag"
);
assert!(ws.pre_evaluate.is_some(), "WS should have pre_evaluate JS");
// Rotation simplified content should NOT contain Evaluate JS
let rot = registry.get("rotation-puzzle").unwrap();
assert!(rot.content.contains("Do NOT write any Evaluate JS"));
assert!(!rot.content.contains("querySelectorAll"));
assert!(
rot.pre_evaluate.is_some(),
"Rotation should have pre_evaluate JS"
);
assert!(
ws.content.contains("Do NOT write any drag"),
"WS skill should prohibit manual drag JS"
);
}
#[test]
fn test_skills_without_pre_evaluate_unchanged() {
let registry = builtin_web_challenges();
// These skills should NOT have pre_evaluate (content-only skills)
for name in &[
"image-grid-selection",
"text-captcha",
"slider-drag",
"checkbox-click",
"license-plate",
"find-waldo",
"chihuahua-muffin",
"reverse-selection",
"affirmations",
] {
let skill = registry
.get(name)
.unwrap_or_else(|| panic!("{} missing", name));
assert!(
skill.pre_evaluate.is_none(),
"{} should NOT have pre_evaluate",
name
);
}
}
#[test]
fn test_new_level_skills_present() {
let registry = builtin_web_challenges();
// L8: License plate
let lp = registry
.get("license-plate")
.expect("license-plate missing");
assert!(lp.matches("", "License Plate Challenge", ""));
assert!(lp.content.contains("license plate"));
// L9: Nested grid
let nested = registry.get("nested-grid").expect("nested-grid missing");
assert!(nested.matches("", "Nested Squares", ""));
assert!(nested.content.contains("SUBDIVIDE"));
assert!(
nested.priority > 5,
"nested-grid should override image-grid"
);
// L10: Whack-a-mole
let wam = registry.get("whack-a-mole").expect("whack-a-mole missing");
assert!(wam.matches("", "Whack a Mole!", ""));
assert!(wam.pre_evaluate.is_some(), "WAM should have pre_evaluate");
let js = wam.pre_evaluate.as_deref().unwrap();
assert!(
js.contains("WAM:"),
"WAM pre_evaluate should set WAM: prefix"
);
// L11: Waldo
let waldo = registry.get("find-waldo").expect("find-waldo missing");
assert!(waldo.matches("", "Where's Waldo", ""));
assert!(waldo.content.contains("striped"));
// L12: Chihuahua vs muffin
let cm = registry
.get("chihuahua-muffin")
.expect("chihuahua-muffin missing");
assert!(cm.matches("", "Muffins? Or Chihuahuas", ""));
assert!(cm.content.contains("CHIHUAHUAS"));
// L13: Reverse selection
let rev = registry
.get("reverse-selection")
.expect("reverse-selection missing");
assert!(rev.matches("", "Reverse CAPTCHA", ""));
assert!(rev.content.contains("OPPOSITE"));
// L14: Affirmations
let aff = registry.get("affirmations").expect("affirmations missing");
assert!(aff.matches("", "Affirmations Level", ""));
assert!(aff.content.contains("I'm not a robot"));
}
#[test]
fn test_nested_overrides_image_grid() {
let registry = builtin_web_challenges();
let nested = registry.get("nested-grid").unwrap();
let img_grid = registry.get("image-grid-selection").unwrap();
assert!(
nested.priority > img_grid.priority,
"nested-grid priority ({}) should be higher than image-grid ({})",
nested.priority,
img_grid.priority
);
}
}