use std::time::Duration;
use crate::AppState;
use crate::config::AutumnConfig;
use crate::plugin_conformance::ConformanceReport;
use crate::system_test::{Page, SystemTest, SystemTestError};
use crate::widget_pack::WidgetPack;
use super::result;
const SCHEMES: [&str; 2] = ["light", "dark"];
pub async fn run_widget_browser_conformance(
pack: &WidgetPack,
axe_source: &str,
) -> Result<ConformanceReport, SystemTestError> {
let mut config = AutumnConfig {
profile: Some("test".into()),
..Default::default()
};
config.security.csrf.enabled = false;
config.stories.enabled = true;
let state = AppState::for_test().with_profile("test");
state.insert_extension(config);
state.insert_extension(pack.gallery().into_registry());
let mut harness = SystemTest::new().state(state);
if let Some(assets) = pack.asset_bundle() {
harness = harness.plugin_assets(assets);
}
let runner = Box::pin(harness.build()).await?;
let page = runner.page().await?;
let mut axe = Vec::new();
let mut console = Vec::new();
let mut keyboard = Vec::new();
for story in pack.stories() {
let slug = story.slug();
let path = format!("{}/{slug}", crate::stories::STORIES_PATH);
let errors_before = page.console_errors().len();
open(&page, &path).await?;
for scheme in SCHEMES {
select_scheme(&page, scheme).await?;
axe.extend(
page.axe_violations(axe_source)
.await?
.into_iter()
.map(|violation| format!("story `{slug}` [{scheme}]: {violation}")),
);
}
keyboard.extend(
tab_problems(&page)
.await?
.into_iter()
.map(|problem| format!("story `{slug}`: {problem}")),
);
keyboard.extend(
roving_problems(&page)
.await?
.into_iter()
.map(|problem| format!("story `{slug}`: {problem}")),
);
tokio::time::sleep(Duration::from_millis(250)).await;
console.extend(
page.console_errors()
.into_iter()
.skip(errors_before)
.map(|error| format!("story `{slug}`: {error}")),
);
}
Ok(ConformanceReport {
plugin_name: pack.name().to_owned(),
checks: vec![
result(
"widget-axe",
axe,
"axe finds no serious or critical violation in either scheme".to_owned(),
"axe violations",
),
result(
"widget-console",
console,
"no console error".to_owned(),
"console errors",
),
result(
"widget-keyboard",
keyboard,
"Tab reaches each control and roving lists move".to_owned(),
"keyboard problems",
),
],
})
}
async fn open(page: &Page, path: &str) -> Result<(), SystemTestError> {
page.visit(path).await?;
wait_for(page, "document.readyState === 'complete'").await?;
wait_for(page, "window.__autumnWidgets === true").await?;
page.expect_hx_settle().await?;
Ok(())
}
async fn select_scheme(page: &Page, scheme: &str) -> Result<(), SystemTestError> {
wait_for(
page,
&format!(
"(() => {{ const r = document.getElementById('story-theme-{scheme}'); \
if (!r) return false; r.click(); return r.checked; }})()"
),
)
.await?;
wait_for(
page,
&format!("getComputedStyle(document.documentElement).colorScheme === '{scheme}'"),
)
.await?;
wait_for(
page,
"document.getAnimations().every(a => !(a instanceof CSSTransition))",
)
.await
}
const MARK_STOPS_JS: &str = r#"(() => {
const preview = document.querySelector('.story-preview');
if (!preview) return '[]';
const sel = 'a[href],area[href],button,input:not([type=hidden]),select,textarea,summary,[tabindex],[contenteditable=""],[contenteditable="true"]';
const tabbable = el => !el.matches(':disabled') && el.tabIndex >= 0 && !el.closest('[inert]')
&& el.checkVisibility({ visibilityProperty: true });
const groups = new Set();
const stops = [];
for (const el of preview.querySelectorAll(sel)) {
if (!tabbable(el)) continue;
if (el.type === 'radio' && el.name) {
const key = el.name + '|' + (el.form ? el.form.id : '');
if (groups.has(key)) continue;
groups.add(key);
const all = [...preview.querySelectorAll('input[type=radio]')]
.filter(r => r.name === el.name && r.form === el.form && tabbable(r));
const pick = all.find(r => r.checked) || all[0];
if (pick !== el) { pick.setAttribute('data-conformance-stop', String(stops.length)); stops.push(pick); continue; }
}
el.setAttribute('data-conformance-stop', String(stops.length));
stops.push(el);
}
return JSON.stringify(stops.map(el => {
const text = (el.getAttribute('aria-label') || el.textContent || el.value || '').trim().slice(0, 30);
return '<' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '> ' + JSON.stringify(text);
}));
})()"#;
const ACTIVE_STOP_JS: &str = r"(() => {
const el = document.activeElement;
const preview = document.querySelector('.story-preview');
if (!el || !preview || !preview.contains(el)) return -2;
const index = el.getAttribute('data-conformance-stop');
return index === null ? -1 : Number(index);
})()";
async fn tab_problems(page: &Page) -> Result<Vec<String>, SystemTestError> {
let labels: Vec<String> = serde_json::from_str(&eval_string(page, MARK_STOPS_JS).await?)
.map_err(|err| SystemTestError::AssertionFailed {
message: format!("tab stops: {err}"),
artifact_path: None,
})?;
if labels.is_empty() {
return Ok(Vec::new());
}
if !eval_bool(
page,
"(() => { const a = document.querySelector('.story-breadcrumb a'); \
if (!a) return false; a.focus(); return document.activeElement === a; })()",
)
.await?
{
return Ok(vec![
"the gallery breadcrumb link cannot take focus".to_owned(),
]);
}
let mut reached = vec![false; labels.len()];
let mut entered = false;
let mut left = false;
for _ in 0..labels.len().saturating_mul(2).saturating_add(2) {
page.press("Tab").await?;
let active = eval_i64(page, ACTIVE_STOP_JS).await?;
if active == -2 {
if entered {
left = true;
break;
}
continue;
}
entered = true;
if let Some(seen) = usize::try_from(active)
.ok()
.and_then(|i| reached.get_mut(i))
{
*seen = true;
}
}
let mut problems: Vec<String> = labels
.iter()
.zip(&reached)
.filter(|(_, reached)| !**reached)
.map(|(label, _)| format!("Tab does not reach {label}"))
.collect();
if entered && !left {
problems.push("focus cannot leave the story with Tab (a keyboard trap)".to_owned());
}
Ok(problems)
}
const ROVING_LISTS_JS: &str = r#"(() => {
const ITEMS = '[role="tab"],[role="menuitem"],[role="option"],[role="radio"],[role="treeitem"]';
const LIST = '[data-autumn-roving],[data-autumn-activedescendant]';
const preview = document.querySelector('.story-preview');
const lists = preview ? [...preview.querySelectorAll('[data-autumn-roving]')]
.filter(list => list.checkVisibility({ visibilityProperty: true })) : [];
return JSON.stringify(lists.map((list, i) => {
list.setAttribute('data-conformance-list', String(i));
return [...list.querySelectorAll(list.getAttribute('data-autumn-roving') || ITEMS)]
.filter(el => !el.hidden && el.getAttribute('aria-disabled') !== 'true' && el.closest(LIST) === list)
.length;
}));
})()"#;
const FOCUS_LIST_JS: &str = r#"(() => {
const ITEMS = '[role="tab"],[role="menuitem"],[role="option"],[role="radio"],[role="treeitem"]';
const LIST = '[data-autumn-roving],[data-autumn-activedescendant]';
const list = document.querySelector('[data-conformance-list="LIST_INDEX"]');
const items = [...list.querySelectorAll(list.getAttribute('data-autumn-roving') || ITEMS)]
.filter(el => !el.hidden && el.getAttribute('aria-disabled') !== 'true' && el.closest(LIST) === list);
const stop = items.find(el => el.tabIndex === 0);
if (!stop) return '';
stop.focus();
if (document.activeElement !== stop) return '';
document.querySelectorAll('[data-conformance-from]').forEach(el => el.removeAttribute('data-conformance-from'));
stop.setAttribute('data-conformance-from', '');
const horizontal = list.getAttribute('data-autumn-orientation') === 'horizontal';
const back = list.getAttribute('data-autumn-wrap') === 'false' && items.indexOf(stop) === items.length - 1;
// In a right-to-left list, ArrowLeft moves forward (as the core does).
const rtl = getComputedStyle(list).direction === 'rtl';
if (horizontal) return back !== rtl ? 'ArrowLeft' : 'ArrowRight';
return back ? 'ArrowUp' : 'ArrowDown';
})()"#;
const MOVED_JS: &str = r#"(() => {
const list = document.querySelector('[data-conformance-list="LIST_INDEX"]');
const a = document.activeElement;
return !!a && list.contains(a) && !a.hasAttribute('data-conformance-from') && a.tabIndex === 0;
})()"#;
async fn roving_problems(page: &Page) -> Result<Vec<String>, SystemTestError> {
let counts: Vec<usize> = serde_json::from_str(&eval_string(page, ROVING_LISTS_JS).await?)
.map_err(|err| SystemTestError::AssertionFailed {
message: format!("roving lists: {err}"),
artifact_path: None,
})?;
let mut problems = Vec::new();
for (i, count) in counts.into_iter().enumerate() {
if count < 2 {
continue;
}
let index = i.to_string();
let key = eval_string(page, &FOCUS_LIST_JS.replace("LIST_INDEX", &index)).await?;
if key.is_empty() {
problems.push(format!("roving list {i} has no item with tabindex=\"0\""));
continue;
}
page.press(&key).await?;
if !eval_bool(page, &MOVED_JS.replace("LIST_INDEX", &index)).await? {
problems.push(format!("{key} does not move focus in roving list {i}"));
}
}
Ok(problems)
}
async fn eval_string(page: &Page, js: &str) -> Result<String, SystemTestError> {
Ok(page
.evaluate(js)
.await?
.into_value::<String>()
.unwrap_or_default())
}
async fn eval_bool(page: &Page, js: &str) -> Result<bool, SystemTestError> {
Ok(page
.evaluate(js)
.await?
.into_value::<bool>()
.unwrap_or(false))
}
async fn eval_i64(page: &Page, js: &str) -> Result<i64, SystemTestError> {
Ok(page.evaluate(js).await?.into_value::<i64>().unwrap_or(-2))
}
async fn wait_for(page: &Page, js: &str) -> Result<(), SystemTestError> {
for _ in 0..100 {
if page
.evaluate(js)
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false)
{
return Ok(());
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(SystemTestError::Timeout {
message: format!("waiting for `{js}`"),
timeout: Duration::from_secs(5),
})
}