A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471#!/usr/bin/env nu
# test-browser.nu — Load the RadikalWiki Dioxus app in a headless browser and# verify DOM state via W3C WebDriver (nushell + curl + jq).## The app is a single Dioxus/WASM SPA served by `dx serve` (debug build). Two# WebDriver backends: headless Servo (default) or real Firefox via geckodriver# (--firefox). Prefer --firefox: Servo masks client-side routing / rendering bugs# that real browsers hit (it hid the whole navigation-stale-view bug).## Beyond DOM assertions it runs a whole-page WCAG contrast audit on each key# screen (test-contrast-audit.js — fails on any green-on-green / faint text; a# self-test proves the detector works), so contrast regressions can't merge.## Usage:# nu test-browser.nu # Unauthenticated smoke tests (Servo)# nu test-browser.nu --firefox # Drive real Firefox (geckodriver)# nu test-browser.nu --firefox --shots # Also save light/dark x desktop/mobile# # PNG images of key screens to ./screenshots# nu test-browser.nu --timeout 30 # Per-wait timeout (seconds)# nu test-browser.nu --verbose # Print WebDriver-server stderr at end# nu test-browser.nu --keep # Keep dx serve + browser running after## # Authenticated tests are opt-in and need credentials (never commit these):# WIKI_EMAIL=you@example.com WIKI_PASSWORD=secret nu test-browser.nu## # The same suite against the AppView: the build with `--features appview`# # and a dev AppView this starts (crates/appview-dev). No account, nothing# # outside this machine, and the authenticated tests always run.# nu test-browser.nu --firefox --appview## Exit codes: 0 all passed · 1 test failure · 2 missing deps / setup failure
const WD_PORT = 7134const SERVE_PORT = 8134const API_PORT = 8137
def wd-url [] { $"http://127.0.0.1:($WD_PORT)" }def base-url [] { $"http://127.0.0.1:($SERVE_PORT)" }# The Hasura GraphQL endpoint the setup/teardown helpers talk to. Env-overridable# (WIKI_GRAPHQL_URL) so the suite can point at a local or staging backend, matching# the app's own compile-time override in src/nhost.rs.def gql-url [] { $env.WIKI_GRAPHQL_URL? | default "https://pgvhpsenoifywhuxnybq.hasura.eu-central-1.nhost.run/v1/graphql"}
# Which backend the app under test is built on: the interim's (the default, and# what ships), or the AppView (`--appview`).def on-appview []: nothing -> bool { ($env.WIKI_BACKEND? | default "") == "appview" }def api-url [] { $"http://127.0.0.1:($API_PORT)" }
# The JS every backend check starts with: `gql(query, variables)`, synchronous,# as the signed-in user. On the AppView the same name is an adapter that answers# the suite's few documents from XRPC (`test-browser-appview-adapter.js`), so# that each check holds both backends to the same thing.def gql-prelude [GQL: string]: nothing -> string { if (on-appview) { open --raw ($env.FILE_PWD | path join "test-browser-appview-adapter.js") | str replace --all "__API__" (api-url) } else { ('var __s;try{__s=JSON.parse(localStorage.getItem("wiki_session"))}catch(e){}var __T=__s?__s.access_token:"";function gql(q,v){var x=new XMLHttpRequest();x.open("POST","' + $GQL + '",false);x.setRequestHeader("content-type","application/json");x.setRequestHeader("authorization","Bearer "+__T);try{x.send(JSON.stringify({query:q,variables:v}))}catch(e){return {errors:[{message:String(e)}]}}try{return JSON.parse(x.responseText)}catch(e){return {errors:[{message:x.responseText}]}}}') }}
# ── Logging ────────────────────────────────────────────────────────────────
def log-info [...msg: string] { print -e $"(ansi blue_bold)[info](ansi reset) ($msg | str join ' ')" }def log-ok [...msg: string] { print -e $"(ansi green_bold)[pass](ansi reset) ($msg | str join ' ')" }def log-fail [...msg: string] { print -e $"(ansi red_bold)[fail](ansi reset) ($msg | str join ' ')" }def log-warn [...msg: string] { print -e $"(ansi yellow_bold)[warn](ansi reset) ($msg | str join ' ')" }
# True when the driving engine is headless Servo, which can't run a handful of# checks (it never fires window-resize events and only partially implements# contenteditable execCommand). Those checks warn-skip on Servo and run for real# under `--firefox`. Returns true (and logs a skip) so callers can `if (servo-skip ...) { } else { <assert> }`.def servo-skip [what: string]: nothing -> bool { if (($env | get -o WIKI_ENGINE | default "servo") == "servo") { log-warn $"skipping ($what) — headless Servo can't verify it \(runs under --firefox)" true } else { false }}
# ── WebDriver helpers (curl + from json) ────────────────────────────────────
def wd-post [path: string, body: string] { try { ^curl -sf -H "Content-Type: application/json" -d $body $"(wd-url)($path)" | from json } catch { null }}def wd-get [path: string] { try { ^curl -sf $"(wd-url)($path)" | from json } catch { null }}def wd-delete [path: string] { try { ^curl -sf -X DELETE $"(wd-url)($path)" | complete | ignore } catch { }}
def wd-new-session [caps: string] { let resp = (wd-post "/session" $caps) if $resp == null { return "" } $resp | get -o value.sessionId | default ($resp | get -o sessionId | default "")}
def wd-navigate [session_id: string, url: string] { wd-post $"/session/($session_id)/url" ({ url: $url } | to json) | ignore}
def wd-set-timeouts [session_id: string, script_ms: int] { wd-post $"/session/($session_id)/timeouts" ({ script: $script_ms, pageLoad: 30000, implicit: 0 } | to json) | ignore}
# Find one element by CSS selector; returns the element id ("" if not found).def wd-find [session_id: string, css: string] { let body = ({ using: "css selector", value: $css } | to json) let resp = (wd-post $"/session/($session_id)/element" $body) if $resp == null { return "" } let val = ($resp | get -o value) if $val == null { return "" } try { $val | values | first } catch { "" }}
def wd-find-all-count [session_id: string, css: string] { let body = ({ using: "css selector", value: $css } | to json) let resp = (wd-post $"/session/($session_id)/elements" $body) if $resp == null { return 0 } try { $resp | get value | length } catch { 0 }}
def wd-text [session_id: string, eid: string] { let resp = (wd-get $"/session/($session_id)/element/($eid)/text") if $resp == null { return "" } $resp | get -o value | default ""}
def wd-click [session_id: string, eid: string] { wd-post $"/session/($session_id)/element/($eid)/click" '{}' | ignore}
def wd-send-keys [session_id: string, eid: string, text: string] { wd-post $"/session/($session_id)/element/($eid)/value" ({ text: $text } | to json) | ignore}
# Execute synchronous JavaScript, returning its value.def wd-execute [session_id: string, script: string] { let resp = (wd-post $"/session/($session_id)/execute/sync" ({ script: $script, args: [] } | to json)) if $resp == null { return null } $resp | get -o value}
# Resize the browser window (for responsive / breakpoint screenshots).def wd-window-rect [session_id: string, w: int, h: int] { wd-post $"/session/($session_id)/window/rect" ({ width: $w, height: $h, x: 0, y: 0 } | to json) | ignore}
# Save a PNG screenshot of the current page.def wd-screenshot [session_id: string, out: string] { let resp = (wd-get $"/session/($session_id)/screenshot") if $resp == null { return } let b64 = ($resp | get -o value | default "") if ($b64 | is-empty) { return } $b64 | decode base64 | save -f $out}
# Self-test: prove the audit actually DETECTS a violation (a passing audit is# only meaningful if the detector works). Inject a known green-on-green element,# confirm the audit flags it, then remove it.def check-contrast-selftest [session_id: string, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { let audit_js = (try { open --raw ($env.FILE_PWD | path join "test-contrast-audit.js") } catch { "" }) if ($audit_js | is-empty) { return { passed: $passed, failed: $failed } } wd-execute $session_id "var d=document.createElement('div'); d.id='__ctest'; d.className='__ctest'; d.style.cssText='color:#006b32;background-color:#008740;font-size:14px;padding:4px'; d.textContent='green on green'; document.body.appendChild(d); return 1" | ignore let raw = (wd-execute $session_id $audit_js) let caught = (try { ($raw | from json | any {|b| (($b.s | str contains "__ctest") or ($b.t | str contains "green on green")) }) } catch { false }) wd-execute $session_id "var e=document.getElementById('__ctest'); if(e)e.remove(); return 1" | ignore if $caught { log-ok "contrast audit self-test: detects a green-on-green violation" { passed: ($passed + 1), failed: $failed } } else { log-fail "contrast audit self-test FAILED: did not detect an injected violation" { passed: $passed, failed: ($failed + 1) } }}
# Run the whole-page contrast audit (test-contrast-audit.js) and fail on any# element below WCAG AA. This is the systematic gate for green-on-green / faint# text: it walks every visible text element, resolves its effective (composited)# background, and checks the real contrast ratio.def check-contrast [session_id: string, label: string, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { let audit_js = (try { open --raw ($env.FILE_PWD | path join "test-contrast-audit.js") } catch { "" }) if ($audit_js | is-empty) { log-warn "contrast audit script missing — skipping"; return { passed: $passed, failed: $failed } } let raw = (wd-execute $session_id $audit_js) if $raw == null { log-warn $"contrast audit errored on ($label)"; return { passed: $passed, failed: $failed } } let bad = (try { $raw | from json } catch { null }) if $bad == null { log-warn $"contrast audit returned non-JSON on ($label)"; return { passed: $passed, failed: $failed } } if ($bad | length) == 0 { log-ok $"contrast OK: ($label)" { passed: ($passed + 1), failed: $failed } } else { log-fail $"($bad | length) low-contrast elements on ($label)" for b in ($bad | take 8) { log-fail $" ($b.r):1 < ($b.m) ($b.s) ($b.t)" } { passed: $passed, failed: ($failed + 1) } }}
# Capture the current view in light + dark theme at desktop + mobile widths, if# screenshots are enabled (--shots sets WIKI_SHOTS). `name` prefixes the files.# The theme rides `data-theme` on <html> (set once by the app at mount), so we# flip it directly — no reload, and it can't be undone by re-renders.def capture-shots [session_id: string, name: string] { if (($env | get -o WIKI_SHOTS | default "") != "1") { return } let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") mkdir $dir for wh in [{ w: 1280, h: 900, tag: "desktop" }, { w: 390, h: 844, tag: "mobile" }] { wd-window-rect $session_id $wh.w $wh.h sleep 200ms for theme in ["light", "dark"] { # Set the theme and force a reflow in one round-trip. wd-execute $session_id ("document.documentElement.setAttribute('data-theme','" + $theme + "'); void document.body.offsetHeight; return 1") | ignore sleep 350ms let out = ($dir | path join $"($name)-($wh.tag)-($theme).png") # Screenshot twice: after a CSS-var (theme) change headless Firefox can # return a pre-repaint frame the first time; the second grabs the paint. wd-screenshot $session_id $out sleep 200ms wd-screenshot $session_id $out } } # Restore the app's default (light) at desktop for subsequent tests. wd-execute $session_id "document.documentElement.setAttribute('data-theme','light'); return 1" | ignore wd-window-rect $session_id 1280 900 sleep 150ms}
# Poll until a CSS selector matches (or timeout in seconds). Returns bool.def wd-wait-for-element [session_id: string, css: string, max_wait: int] { mut elapsed = 0 while $elapsed < $max_wait { let eid = (try { wd-find $session_id $css } catch { "" }) if ($eid | is-not-empty) and $eid != "null" { return true } sleep 500ms $elapsed = $elapsed + 1 } false}
# Wait for the Dioxus app to mount into #main (non-empty innerHTML).def wd-wait-for-mount [session_id: string, max_wait: int] { mut elapsed = 0 while $elapsed < $max_wait { let len = (try { wd-execute $session_id 'return (document.getElementById("main")||{innerHTML:""}).innerHTML.length' } catch { 0 }) if ($len != null) and ($len != 0) { return true } sleep 500ms $elapsed = $elapsed + 1 } false}
# Poll a JS snippet (must `return "y"` when ready) every 150ms up to max_ms.# Returns as soon as it is ready, so it replaces a fixed sleep after an async# navigation/action while keeping a safety cap. A small settle follows on success.def wd-wait-y [session_id: string, js: string, max_ms: int] { mut waited = 0 while $waited < $max_ms { if ((try { wd-execute $session_id $js } catch { "n" }) == "y") { sleep 150ms; return true } sleep 150ms $waited = $waited + 150 } false}
# ── Assertion helpers ───────────────────────────────────────────────────────
def assert-exists [session_id: string, label: string, css: string, --passed (-p): int, --failed (-f): int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed let eid = (try { wd-find $session_id $css } catch { "" }) if ($eid | is-not-empty) and $eid != "null" { log-ok $label; $p = $p + 1 } else { log-fail $"($label) — not found: ($css)"; $fl = $fl + 1 } { passed: $p, failed: $fl }}
def assert-count [session_id: string, label: string, css: string, min: int, --passed (-p): int, --failed (-f): int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed let n = (try { wd-find-all-count $session_id $css } catch { 0 }) if $n >= $min { log-ok $"($label) \(($n))"; $p = $p + 1 } else { log-fail $"($label) — expected >= ($min), got ($n)"; $fl = $fl + 1 } { passed: $p, failed: $fl }}
def assert-contains [session_id: string, label: string, css: string, substr: string, --passed (-p): int, --failed (-f): int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed let eid = (try { wd-find $session_id $css } catch { "" }) if ($eid | is-empty) or $eid == "null" { log-fail $"($label) — not found: ($css)"; $fl = $fl + 1 } else { let t = (wd-text $session_id $eid) if ($t | str contains $substr) { log-ok $label; $p = $p + 1 } else { log-fail $"($label) — expected to contain \"($substr)\", got \"($t)\""; $fl = $fl + 1 } } { passed: $p, failed: $fl }}
# ── Process / port helpers ──────────────────────────────────────────────────
def kill-port [port: int] { let in_use = (try { ^fuser $"($port)/tcp" | complete; true } catch { false }) if $in_use { log-warn $"Port ($port) in use — killing stale process" try { ^fuser -k $"($port)/tcp" | complete } catch { } sleep 500ms }}
def servo-bin [] { if (which servoshell | is-not-empty) { "servoshell" } else if (which servo | is-not-empty) { "servo" } else { "" }}
# Shell command that starts geckodriver on the WebDriver port. Prefers a# geckodriver + firefox already on PATH; otherwise fetches them via nix. Real# Firefox catches client-side routing / rendering bugs that headless Servo masks.def gecko-cmd [log: string] { if (which geckodriver | is-not-empty) and (which firefox | is-not-empty) { $'geckodriver --port ($WD_PORT) > "($log)" 2>&1 & echo $!' } else { $'nix shell nixpkgs#geckodriver nixpkgs#firefox --command geckodriver --port ($WD_PORT) > "($log)" 2>&1 & echo $!' }}
# What the dev AppView said of itself, or an empty record until it has.def read-hello [path: string]: nothing -> record { let raw = (try { open --raw $path | str trim } catch { "" }) if ($raw | is-empty) { return {} } try { $raw | from json } catch { {} }}
# Start `crates/appview-dev` for `--appview`, with the wiki the suite expects# to find around its own fixtures: the owner runs the site, and sits as a plain# member in one group that is somebody else's to run, which is what the# owner-gated checks need and an owner of everything would never see. Returns# its pid, or 0 with the reason logged.def start-dev-appview []: nothing -> int { kill-port $API_PORT log-info "Building and starting the dev AppView..." let built = (do -i { ^cargo build --quiet -p appview-dev --manifest-path crates/Cargo.toml } | complete) if $built.exit_code != 0 { log-fail "appview-dev did not build"; print -e $built.stderr; return 0 } let hello = (^mktemp /tmp/wiki-appview-XXXXXX.json | str trim) let pid = (^bash -c $'APPVIEW_FRONTEND_ORIGINS=(base-url) crates/target/debug/appview-dev --port ($API_PORT) did:plc:owner did:plc:member > "($hello)" 2>/dev/null < /dev/null & echo $!' | str trim | into int) mut said = {} for _ in 1..40 { $said = (read-hello $hello) if ($said | is-not-empty) { break } sleep 250ms } rm -f $hello if ($said | is-empty) { log-fail "the dev AppView did not start"; return 0 } let owner = ($said.sessions | get "did:plc:owner") let member = ($said.sessions | get "did:plc:member") let xrpc = $"(api-url)/xrpc/wiki.radikal" let post = {|token: string, method: string, body: record| ^curl -s -X POST -H $"authorization: Bearer ($token)" -H "content-type: application/json" -d ($body | to json -r) $"($xrpc).($method)" | from json } let get = {|token: string, call: string| ^curl -s -H $"authorization: Bearer ($token)" $"($xrpc).($call)" | from json } # Made by the owner, since only who runs the site starts a group at its top, # then handed to the member, and the owner steps down to a seat in it. let group = (do $post $owner "createContext" {parent_id: "home", kind: "group", name: "E2E Foreign"} | get id) do $post $owner "inviteMembers" {context_id: $group, invites: [{name: "Medlem", did: "did:plc:member"}]} | ignore let invitation = (do $get $member "listInvitations" | get invitations | first | get id) do $post $member "acceptInvitation" {id: $invitation} | ignore let seats = (do $get $owner $"listMembers?context=($group)" | get members) do $post $owner "updateMember" {id: ($seats | where user_did == "did:plc:member" | first | get id), owner: true} | ignore do $post $owner "updateMember" {id: ($seats | where user_did == "did:plc:owner" | first | get id), owner: false} | ignore $pid}
def do-cleanup [session_id: string, driver_pid: int, server_pid: int, keep_server: bool] { if ($session_id | is-not-empty) { wd-delete $"/session/($session_id)" } # Always tear down the browser; keep the dev server when it was reused. let pids = if $keep_server { [$driver_pid] } else { [$driver_pid $server_pid] } for pid in $pids { if $pid > 0 { let alive = (do -i { ^kill -0 $pid } | complete) if $alive.exit_code == 0 { do -i { ^kill $pid } | complete | ignore } } } # geckodriver is launched through a nix wrapper whose pid isn't the driver's, # so sweep the WebDriver port too. Leave the serve port alone when reusing. kill-port $WD_PORT if not $keep_server { kill-port $SERVE_PORT } # The dev AppView holds nothing worth keeping: it is in memory. if (on-appview) { kill-port $API_PORT }}
# ── Tests: unauthenticated shell ────────────────────────────────────────────
def test-shell [session_id: string, timeout: int, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed log-info "" log-info "── App shell (unauthenticated) ──────────────────────────"
wd-navigate $session_id $"(base-url)/" if not (wd-wait-for-mount $session_id 120) { log-fail "App did not mount into #main" return { passed: $p, failed: ($fl + 1) } } sleep 300ms
let r = (assert-exists $session_id "app shell mounts" "#main .app-shell" -p $p -f $fl); $p = $r.passed; $fl = $r.failed # <html lang> must be set so `hyphens: auto` (document text, node names) actually # hyphenates — without a lang attribute the browser has no dictionary. let htmllang = (wd-execute $session_id 'return document.documentElement.getAttribute("lang")||""') if ($htmllang == "da") or ($htmllang == "en") { log-ok $"html lang set to '($htmllang)' for hyphenation"; $p = $p + 1 } else { log-fail $"html lang not set: '($htmllang)'"; $fl = $fl + 1 } # The welcome card's title is the home (root) node's name; unauthenticated it # falls back to the default. Assert a non-empty title renders (data-driven). let wtitle = (wd-execute $session_id 'var h=document.querySelector("#main .home-hero-title"); return h?h.innerText.trim():""') if ($wtitle | is-not-empty) { log-ok "welcome card shows a title"; $p = $p + 1 } else { log-fail "welcome card title missing"; $fl = $fl + 1 } let r = (assert-exists $session_id "log in link present" '#main a[href="/user/login"]' -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (assert-exists $session_id "register link present" '#main a[href="/user/register"]' -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (check-contrast-selftest $session_id $p $fl); $p = $r.passed; $fl = $r.failed let r = (check-contrast $session_id "logged-out shell" $p $fl); $p = $r.passed; $fl = $r.failed
# PWA (#33): the app is installable — a manifest link + brand theme-color are # injected into the head (by pwa::setup), the manifest (a runtime blob) # declares name/start_url/icons, and the icon it points to actually serves. let mhref = (wd-execute $session_id 'var l=document.querySelector("link[rel=manifest]"); return l?(l.getAttribute("href")):"none"') if ($mhref != "none") and ($mhref | str starts-with "blob:") { log-ok "PWA manifest linked (blob)"; $p = $p + 1 } else { log-fail $"no PWA manifest link: ($mhref)"; $fl = $fl + 1 } let tcolor = (wd-execute $session_id 'var m=document.querySelector("meta[name=theme-color]"); return m?(m.getAttribute("content")):"none"') if $tcolor != "none" { log-ok $"PWA theme-color: ($tcolor)"; $p = $p + 1 } else { log-fail "no theme-color meta"; $fl = $fl + 1 } # Fetch the manifest blob + its icon synchronously; validate the essentials. let mstatus = (wd-execute $session_id 'try{var l=document.querySelector("link[rel=manifest]"); var x=new XMLHttpRequest(); x.open("GET",l.href,false); x.send(); var m=JSON.parse(x.responseText); if(!(m.name&&m.start_url&&m.icons&&m.icons.length))return "invalid"; var y=new XMLHttpRequest(); y.open("GET",m.icons[0].src,false); y.send(); return (y.status===200&&y.responseText.indexOf("<svg")>=0)?"ok":("icon:"+y.status)}catch(e){return "err:"+e}') if $mstatus == "ok" { log-ok "PWA manifest + icon valid and served"; $p = $p + 1 } else { log-fail $"PWA manifest/icon check: ($mstatus)"; $fl = $fl + 1 }
# Pull-to-refresh: the indicator mounts, and an over-scroll up at the top # triggers the refreshing animation (synthetic wheel event; scrollY is 0). let r = (assert-exists $session_id "pull-to-refresh indicator mounts" ".ptr-indicator" -p $p -f $fl); $p = $r.passed; $fl = $r.failed wd-execute $session_id "window.scrollTo(0,0); var e=new WheelEvent('wheel',{deltaY:-300,bubbles:true,cancelable:true}); window.dispatchEvent(e); return 1" | ignore mut ptr_seen = false mut ptr_tries = 0 while (not $ptr_seen) and $ptr_tries < 6 { sleep 120ms let rc = (wd-execute $session_id "return document.querySelector('.ptr-indicator.refreshing')?'y':'n'") if $rc == "y" { $ptr_seen = true } $ptr_tries = $ptr_tries + 1 } if $ptr_seen { log-ok "pull-to-refresh triggers the refreshing animation"; $p = $p + 1 } else { log-fail "over-scroll up did not trigger the refresh animation"; $fl = $fl + 1 }
# Client-side routing into the login page. let login = (try { wd-find $session_id '#main a[href="/user/login"]' } catch { "" }) if ($login | is-not-empty) and $login != "null" { wd-click $session_id $login sleep 500ms if (on-appview) { # One thing to type: the password is the provider's to ask for. let r = (assert-exists $session_id "login route asks for a handle" '#main input#auth-handle' -p $p -f $fl); $p = $r.passed; $fl = $r.failed let asked = (wd-execute $session_id 'return document.querySelectorAll("#main input[type=password]").length') if $asked == 0 { log-ok "login route asks for no password"; $p = $p + 1 } else { log-fail $"login route has ($asked) password fields"; $fl = $fl + 1 } } else { let r = (assert-exists $session_id "login route has email input" '#main input[type=email]' -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (assert-exists $session_id "login route has password input" '#main input[type=password]' -p $p -f $fl); $p = $r.passed; $fl = $r.failed } let path = (wd-execute $session_id 'return location.pathname') if $path == "/user/login" { log-ok "client-side routing to /user/login"; $p = $p + 1 } else { log-fail $"expected /user/login, got ($path)"; $fl = $fl + 1 } } else { log-fail "could not click login link"; $fl = $fl + 1 }
{ passed: $p, failed: $fl }}
# ── Tests: authenticated (opt-in via WIKI_EMAIL / WIKI_PASSWORD) ─────────────
def test-auth [session_id: string, email: string, password: string, timeout: int, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed log-info "" log-info "── Authenticated home list ──────────────────────────────"
# Fresh login: mount unauthenticated first (a reload with a stored session # currently trips a flaky Servo wasm panic — see PLAN.md), then sign in. wd-navigate $session_id $"(base-url)/" wd-execute $session_id 'try{localStorage.clear()}catch(e){}; return "ok"' | ignore wd-navigate $session_id $"(base-url)/" if not (wd-wait-for-mount $session_id 120) { log-fail "unauthenticated home did not mount"; return { passed: $p, failed: ($fl + 1) } }
if (on-appview) { # As a provider sends a browser back: a one-time code after `#`, which the # dev AppView mints for whoever asks. The provider's own pages are # `scripts/test-real-login.nu`'s to go through. let code = (^curl -s $"(api-url)/dev/code?did=did:plc:owner" | from json | get code) wd-navigate $session_id $"(base-url)/user/login#code=($code)" } else { wd-navigate $session_id $"(base-url)/user/login" if not (wd-wait-for-element $session_id '#main input[type=email]' 40) { log-fail "login form did not render"; return { passed: $p, failed: ($fl + 1) } } sleep 300ms
wd-send-keys $session_id (wd-find $session_id '#main input[type=email]') $email wd-send-keys $session_id (wd-find $session_id '#main input[type=password]') $password wd-click $session_id (wd-find $session_id '#main button') }
# Wait for the session to land and the drawer to populate. mut ok = false for _ in 1..($timeout) { let authed = (wd-execute $session_id 'return localStorage.getItem("wiki_session")?"y":"n"') if $authed == "y" { $ok = true; break } sleep 1sec } if not $ok { log-fail "login did not establish a session"; return { passed: $p, failed: ($fl + 1) } } log-ok "login established a session"; $p = $p + 1 # Wait for the authed home to render (root node + drawer) rather than a fixed 3s. wd-wait-y $session_id 'return document.querySelector("#main .card .headline-small")?"y":"n"' 4000 | ignore
# ── Adaptive WindowSizeClass reacts to resize (M3 nav foundation) ──── # The reactive size-class signal is written from a resize listener bridged # through a coroutine; this proves the bridge works (no runtime panic) and the # `data-size-class` attribute tracks the window width live. mkdir screenshots wd-window-rect $session_id 1280 900 sleep 500ms let sc_wide = (wd-execute $session_id 'var s=document.querySelector(".app-shell"); return s?s.getAttribute("data-size-class"):"none"') wd-window-rect $session_id 460 900 sleep 700ms let sc_narrow = (wd-execute $session_id 'var s=document.querySelector(".app-shell"); return s?s.getAttribute("data-size-class"):"none"') wd-window-rect $session_id 1280 900 sleep 700ms let sc_back = (wd-execute $session_id 'var s=document.querySelector(".app-shell"); return s?s.getAttribute("data-size-class"):"none"') if (servo-skip "window-size-class resize tracking") { # Servo never dispatches the window `resize` event, so the size class can't # update — this reflects the engine, not the app (verified under Firefox). } else if ($sc_wide == "large") and ($sc_narrow == "compact") and ($sc_back == "large") { log-ok $"window size class reacts to resize \(($sc_wide) -> ($sc_narrow) -> ($sc_back))"; $p = $p + 1 } else { log-fail $"window size class did not track resize \(wide=($sc_wide) narrow=($sc_narrow) back=($sc_back))"; $fl = $fl + 1 }
# ── Stale JWT recovery (the "returning to a tab" bug) ──────────────── # Corrupt the stored access token's signature but keep its expiry in the # future, so the startup refresh does NOT fire. On reload the home data query # hits the bad token; the fix must refresh + retry so groups/events still load # instead of surfacing a JWT error. Skipped on Servo, which doesn't reliably # rehydrate the authed shell after a full-page navigation (so the refresh+retry # can't be exercised, and a left-over corrupt token would break the welcome # check below) — verified under Firefox. if (servo-skip "stale-JWT refresh+retry recovery") { } else if (on-appview) { # A bearer that lasts a month, with nothing to refresh it by: a spoiled # one is a sign-out, which is its own check. log-info "no token refresh on the AppView: skipping the stale-JWT check" } else { let jwt_corrupt = (wd-execute $session_id 'try { var s=JSON.parse(localStorage.getItem("wiki_session")); if(!s || !s.access_token) return "nosession"; s.access_token = s.access_token.slice(0,-6) + "AAAAAA"; localStorage.setItem("wiki_session", JSON.stringify(s)); return "ok"; } catch(e){ return "err:"+e; }') if $jwt_corrupt != "ok" { log-warn $"could not stage JWT-recovery check: ($jwt_corrupt)" } wd-navigate $session_id $"(base-url)/" # The stale token triggers a refresh + retry, then the drawer's groups/events # load; poll for them (capped) instead of a fixed 5s. wd-wait-y $session_id 'return document.querySelectorAll(".nav-rail-tree .list-item").length>0?"y":"n"' 6000 | ignore let jwt_items = (wd-execute $session_id 'return document.querySelectorAll(".nav-rail-tree .list-item").length') let jwt_tail = (wd-execute $session_id 'try { var s=JSON.parse(localStorage.getItem("wiki_session")); return s.access_token.slice(-6); } catch(e){ return "err"; }') let jwt_n = (try { $jwt_items | into int } catch { 0 }) if $jwt_corrupt == "ok" { if ($jwt_n > 0) and ($jwt_tail != "AAAAAA") { log-ok $"stale JWT recovered: token refreshed, ($jwt_n) groups/events loaded"; $p = $p + 1 } else { log-fail $"stale JWT did not recover \(drawer items=($jwt_n), token tail=($jwt_tail))"; $fl = $fl + 1 } } } sleep 1sec
# The welcome card renders for the authed user with the home (root) node's name # as its title. This also proves the catch-all serves `/` (empty segments -> # root node -> wiki/home -> HomeApp). let htitle = (wd-execute $session_id 'var h=document.querySelector("#main .card .home-hero-title"); return h?h.innerText.trim():""') if ($htitle | is-not-empty) { log-ok "home welcome card renders with a title" ; $p = $p + 1 } else { log-fail "home welcome card title missing" ; $fl = $fl + 1 } # Long group/event lists collapse to the newest few with a "Show all (N)" # toggle; clicking it reveals the rest (the test account owns many events). let toggle = (wd-execute $session_id 'var b=[...document.querySelectorAll(".list-expand-toggle")].find(function(x){return x.textContent.indexOf("Show all")>=0}); if(!b) return "notoggle"; var before=document.querySelectorAll(".list-item").length; b.click(); return JSON.stringify({before:before})') if $toggle == "notoggle" { log-warn "no show-all toggle (account has few contexts) — skipping" } else { sleep 400ms let after = (wd-execute $session_id 'return document.querySelectorAll(".list-item").length') let b = (($toggle | from json).before) if ($after > $b) { log-ok $"show-all expands the list \(($b) -> ($after))"; $p = $p + 1 } else { log-fail $"show-all did not expand \(($b) -> ($after))"; $fl = $fl + 1 } } let r = (check-contrast $session_id "home (authenticated)" $p $fl); $p = $r.passed; $fl = $r.failed capture-shots $session_id "home"
# The root editor is reachable at `/?app=editor` (the owner "edit" button on # the welcome card links here). The root has no URL path, so this exercises the # dedicated `/` route sharing the resolver, not a separate `/edit/welcome` route. wd-navigate $session_id $"(base-url)/?app=editor" wd-wait-y $session_id 'return (document.querySelector(".author-field") && document.querySelector("[contenteditable]"))?"y":"n"' 5000 | ignore let root_ed = (wd-execute $session_id 'return (document.querySelector(".author-field") && document.querySelector("[contenteditable]"))?"y":"n"') if $root_ed == "y" { log-ok "root editor renders at /?app=editor"; $p = $p + 1 } else { log-fail "root editor did not render at /?app=editor"; $fl = $fl + 1 } # Returning to `/` must show the welcome again (the `Home` route round-trips; # regression: an empty catch-all serialized to a relative "?" and stuck). wd-navigate $session_id $"(base-url)/" wd-wait-y $session_id 'return [...document.querySelectorAll(".material-icons")].some(function(e){return e.textContent.trim()==="waving_hand"})?"y":"n"' 4000 | ignore let back_home = (wd-execute $session_id 'return [...document.querySelectorAll(".material-icons")].some(function(e){return e.textContent.trim()==="waving_hand"})?"y":"n"') if $back_home == "y" { log-ok "navigating back to / shows the welcome"; $p = $p + 1 } else { log-fail "/ did not show the welcome after the editor"; $fl = $fl + 1 }
# ── Theme colour picker: pick a primary seed, the app re-skins ────── # Open the user menu, pick a non-active primary swatch, and assert the live # --md-sys-color-primary token changes; then Reset reverts it to the brand. wd-execute $session_id 'var b=document.querySelector(".user-menu > button"); if(b) b.click(); return "ok"' | ignore sleep 600ms let cp_before = (wd-execute $session_id 'return getComputedStyle(document.documentElement).getPropertyValue("--md-sys-color-primary").trim()') let cp_click = (wd-execute $session_id 'var p=document.querySelector(".menu-color-section .color-picker"); if(!p) return "nopicker"; var sw=p.querySelectorAll("button.color-swatch"); if(sw.length<2) return "noswatch"; (sw[1].classList.contains("active")?sw[2]:sw[1]).click(); return "ok"') sleep 800ms let cp_after = (wd-execute $session_id 'return getComputedStyle(document.documentElement).getPropertyValue("--md-sys-color-primary").trim()') if $cp_click == "ok" and ($cp_before != $cp_after) and ($cp_after | is-not-empty) { log-ok $"color picker re-skins the app \(--md-sys-color-primary ($cp_before) -> ($cp_after))"; $p = $p + 1 } else { log-fail $"color picker did not change the primary token \(click=($cp_click) before=($cp_before) after=($cp_after))"; $fl = $fl + 1 } # Selecting the first (brand) swatch restores the default (no reset button). let cp_reset = (wd-execute $session_id 'var p=document.querySelector(".menu-color-section .color-picker"); if(!p) return "nopicker"; var sw=p.querySelectorAll("button.color-swatch"); if(!sw.length) return "noswatch"; sw[0].click(); return "ok"') sleep 700ms let cp_reverted = (wd-execute $session_id 'return getComputedStyle(document.documentElement).getPropertyValue("--md-sys-color-primary").trim()') if $cp_reset == "ok" and ($cp_reverted == $cp_before) { log-ok "selecting the first swatch restores the brand primary"; $p = $p + 1 } else { log-fail $"first swatch did not restore the brand primary \(click=($cp_reset) expected=($cp_before) got=($cp_reverted))"; $fl = $fl + 1 } # Close the menu via its toggle (it is open here) so the next test opens it # from a known-closed state. A programmatic body click does not dismiss it. wd-execute $session_id 'var b=document.querySelector(".user-menu > button"); if(b) b.click(); return "ok"' | ignore sleep 400ms
# User menu: clicking the avatar opens a dropdown that stays fully within the # viewport, and the trigger has no stray border (regression: the primitive # drew a rounded square and positioned the popup off-screen). let umbtn = (wd-execute $session_id 'var b=document.querySelector(".user-menu > button"); if(b){b.click(); return "y"} return "n"') if $umbtn == "y" { sleep 400ms let dd = (wd-execute $session_id 'return document.querySelector(".user-menu-dropdown")?"y":"n"') if $dd == "y" { log-ok "user menu opens a dropdown"; $p = $p + 1 # The account menu now carries the signed-in identity header (moved # out of the sidebar). let hdr = (wd-execute $session_id 'var h=document.querySelector(".user-menu-dropdown .user-menu-header"); if(!h) return "none"; var e=h.querySelector(".user-menu-email"); return e?e.innerText.trim():"nomail"') if ($hdr | str contains "@") { log-ok "account menu shows the signed-in identity"; $p = $p + 1 } else { log-fail $"account menu identity header missing: ($hdr)"; $fl = $fl + 1 } if (($env | get -o WIKI_SHOTS | default "") == "1") { mkdir screenshots wd-screenshot $session_id "screenshots/user-menu.png" sleep 150ms wd-screenshot $session_id "screenshots/user-menu.png" } let inview = (wd-execute $session_id 'var d=document.querySelector(".user-menu-dropdown"); var r=d.getBoundingClientRect(); return (r.left>=-1 && r.top>=-1 && r.right<=window.innerWidth+1 && r.bottom<=window.innerHeight+1)?"y":JSON.stringify({l:Math.round(r.left),t:Math.round(r.top),r:Math.round(r.right),b:Math.round(r.bottom),w:window.innerWidth,h:window.innerHeight})') if $inview == "y" { log-ok "user menu popup is within the viewport"; $p = $p + 1 } else { log-fail $"user menu popup off-screen: ($inview)"; $fl = $fl + 1 } # The account row's only border is the intentional top separator; no # stray side/bottom button chrome. let bw = (wd-execute $session_id 'var b=document.querySelector(".user-menu > button"); var s=getComputedStyle(b); return s.borderLeftWidth+"/"+s.borderBottomWidth') if (($bw | default "") == "0px/0px") { log-ok "account trigger has no stray border"; $p = $p + 1 } else { log-warn $"account trigger borders: ($bw)" } # The avatar must stand out from the green bar (the green-on-green # complaint): its background should differ clearly from the bar's. let avc = (wd-execute $session_id 'function L(c){var a=c.map(function(v){v/=255;return v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4)});return 0.2126*a[0]+0.7152*a[1]+0.0722*a[2]} function P(s){var m=s.match(/[0-9.]+/g);return m?[+m[0],+m[1],+m[2]]:[0,0,0]} function R(x,y){var p=L(P(x)),q=L(P(y)),h=Math.max(p,q),l=Math.min(p,q);return (h+0.05)/(l+0.05)} var av=document.querySelector(".user-menu .avatar"); var bar=document.querySelector(".bar"); if(!av||!bar) return "no"; return R(getComputedStyle(av).backgroundColor, getComputedStyle(bar).backgroundColor).toFixed(2)') if $avc == "no" { log-warn "could not measure avatar/bar contrast" } else if ((try { $avc | into float } catch { 0.0 }) >= 1.4) { log-ok $"user avatar stands out from the bar ($avc):1"; $p = $p + 1 } else { log-fail $"user avatar blends into the bar: ($avc):1"; $fl = $fl + 1 } # Dark-mode toggle is now an accessible Switch (role=switch); clicking # it must flip <html data-theme>. Toggle it back so later screens keep # their theme. let sw = (wd-execute $session_id 'return document.querySelector(".user-menu-dropdown [role=switch]")?"y":"n"') if $sw == "y" { let before = (wd-execute $session_id 'return document.documentElement.getAttribute("data-theme")||"light"') wd-execute $session_id 'document.querySelector(".user-menu-dropdown [role=switch]").click(); return 1' | ignore sleep 400ms let after = (wd-execute $session_id 'return document.documentElement.getAttribute("data-theme")||"light"') if $before != $after { log-ok $"theme switch flips data-theme ($before) to ($after)"; $p = $p + 1 } else { log-fail $"theme switch did not change data-theme, stayed ($before)"; $fl = $fl + 1 } # Now checked: the switch must use the M3 green accent, not the dx # grayscale (regression guard for the primitive re-theming). let swbg = (wd-execute $session_id 'var s=document.querySelector(".user-menu-dropdown [role=switch]"); if(!s) return "none"; var m=getComputedStyle(s).backgroundColor.match(/[0-9.]+/g); return m?JSON.stringify({r:+m[0],g:+m[1],b:+m[2]}):"none"') if $swbg != "none" { let c = ($swbg | from json) if ($c.g > $c.r) and ($c.g > $c.b) { log-ok $"checked switch uses the green accent rgb\(($c.r),($c.g),($c.b)\)"; $p = $p + 1 } else { log-fail $"checked switch not green: rgb\(($c.r),($c.g),($c.b)\)"; $fl = $fl + 1 } } else { log-warn "could not read switch colour" } wd-execute $session_id 'document.querySelector(".user-menu-dropdown [role=switch]").click(); return 1' | ignore sleep 300ms } else { log-fail "theme switch not found in user menu"; $fl = $fl + 1 } wd-execute $session_id 'var bd=document.querySelector(".menu-backdrop"); if(bd)bd.click(); return 1' | ignore sleep 200ms } else { log-fail "user menu did not open a dropdown"; $fl = $fl + 1 } } else { log-warn "user menu button not found — skipping user-menu check" }
# Groups + events render as context items (avatar badges) in the drawer. let r = (assert-count $session_id "drawer shows group/event items" ".nav-rail-tree .avatar.secondary" 1 -p $p -f $fl); $p = $r.passed; $fl = $r.failed
# ── In-context navigation (drawer node tree + app rail) ────────────── # Stand up a hermetic fixture of our own — a fresh GROUP (its own context, with # the permission template seeded) that CONTAINS a fresh EVENT plus a folder and # a policy — and run all the in-context checks against it. This makes the suite # self-contained: it no longer depends on whatever pre-existing group happens to # be first in the account (e.g. `/test`), and it tears the fixture down at the # end. If the setup fails (e.g. Servo XHR quirks), fall back to discovering an # existing populated context so the run still exercises the in-context views. let GQL = (gql-url) # gql() prelude: read the session token from localStorage, sync-XHR to Hasura. let gql = (gql-prelude $GQL) # Per-context permission template (mirrors graphql::context_permission_objects), # plus a wiki/event row so an event can be nested inside the group. let perm_fn = 'function permObjs(cid){var R=[["vote/vote","member",["vote/poll"]],["vote/policy","member",["wiki/folder"]],["vote/candidate","member",["vote/position"]],["wiki/document","owner",["wiki/event","wiki/folder","wiki/group"]],["vote/poll","owner",["vote/policy","vote/change","vote/position"]],["vote/question","member",["vote/position","wiki/file"]],["vote/comment","member",["vote/policy","vote/change"]],["speak/speak","member",["speak/list"]],["vote/change","member",["vote/policy","vote/change","wiki/file"]],["wiki/folder","owner",["wiki/folder","wiki/group","wiki/event"]],["vote/position","owner",["wiki/folder"]],["wiki/file","owner",["wiki/event","wiki/folder","wiki/group"]],["wiki/event","owner",["wiki/group","wiki/folder"]]];return R.map(function(r){var m=r[0]!="vote/vote";return {contextId:cid,nodeId:cid,mimeId:r[0],role:r[1],parents:r[2],active:true,insert:true,select:true,update:m,delete:m};});}' # Group (own context + perms) -> event (own context + perms) + folder -> policy. let setup_js = ($gql + $perm_fn + 'var INS="mutation($o:nodes_insert_input!){insertNode(object:$o){id key}}";var UPD="mutation($id:uuid!,$s:nodes_set_input!){updateNode(pk_columns:{id:$id},_set:$s){id}}";var PERM="mutation($o:[permissions_insert_input!]!){insertPermissions(objects:$o){affected_rows}}";var rt=gql("query{nodes(where:{mimeId:{_eq:\"wiki/home\"}}){id contextId}}",{});var ROOT=null,RC=null;try{ROOT=rt.data.nodes[0].id;RC=rt.data.nodes[0].contextId}catch(e){}var out={group:null,groupKey:null,event:null,eventKey:null,folder:null,folderKey:null,policy:null,policyKey:null,member:0,err:null};if(!ROOT){out.err="root not found";return JSON.stringify(out);}var t=Date.now();var CONTENT=[{type:"heading-one",children:[{text:"E2E Suite"}]},{type:"paragraph",children:[{text:"Hermetic fixture content for the in-context checks."}]},{type:"heading-two",children:[{text:"Section"}]},{type:"paragraph",children:[{text:"Body text so the landing view renders a populated document card."}]}];var g=gql(INS,{o:{name:"E2E Suite "+t,key:"e2e-suite-"+t,mimeId:"wiki/group",parentId:ROOT,contextId:RC,mutable:true,data:{content:CONTENT}}});try{out.group=g.data.insertNode.id;out.groupKey=g.data.insertNode.key}catch(e){}if(!out.group){out.err=g.errors?JSON.stringify(g.errors):"group insert failed";return JSON.stringify(out);}gql(UPD,{id:out.group,s:{contextId:out.group,mutable:false}});var ps=gql(PERM,{o:permObjs(out.group)});if(ps.errors){out.err="perm seed: "+JSON.stringify(ps.errors);return JSON.stringify(out);}var ME=null;try{ME=__s.user}catch(e){}function ownRow(cid){if(!ME)return;gql("mutation($o:[members_insert_input!]!){insertMembers(objects:$o){affected_rows}}",{o:[{parentId:cid,nodeId:ME.id,email:ME.email,name:(ME.display_name||ME.email),owner:true,accepted:true,active:true}]});}ownRow(out.group);var ev=gql(INS,{o:{name:"E2E Event "+t,key:"e2e-evt-"+t,mimeId:"wiki/event",parentId:out.group,contextId:out.group,mutable:true}});try{out.event=ev.data.insertNode.id;out.eventKey=ev.data.insertNode.key}catch(e){}if(out.event){gql(UPD,{id:out.event,s:{contextId:out.event,mutable:false}});gql(PERM,{o:permObjs(out.event)});ownRow(out.event);}else{out.err=ev.errors?("event: "+JSON.stringify(ev.errors)):"event insert failed";}var f=gql(INS,{o:{name:"E2E Docs",key:"e2e-fld-"+t,mimeId:"wiki/folder",parentId:out.group,contextId:out.group,mutable:true}});try{out.folder=f.data.insertNode.id;out.folderKey=f.data.insertNode.key}catch(e){}if(!out.folder){out.err=(out.err?out.err+"; ":"")+(f.errors?JSON.stringify(f.errors):"folder insert failed");return JSON.stringify(out);}var pl=gql(INS,{o:{name:"E2E policy",key:"e2e-pol-"+t,mimeId:"vote/policy",parentId:out.folder,contextId:out.group,mutable:true}});try{out.policy=pl.data.insertNode.id;out.policyKey=pl.data.insertNode.key}catch(e){}if(!out.policy){out.err=(out.err?out.err+"; ":"")+(pl.errors?JSON.stringify(pl.errors):"policy insert failed");}var mem=gql("mutation($o:[members_insert_input!]!){insertMembers(objects:$o){affected_rows}}",{o:[{parentId:out.group,name:"E2E Member",email:"e2e-member-"+t+"@example.com"}]});try{out.member=mem.data.insertMembers.affected_rows}catch(e){}return JSON.stringify(out);') # The same fixture by the AppView's own calls: there is no row to insert a # node as, and whoever makes a context owns it, with no permission rows. let setup_appview_js = ($gql + 'var out={group:null,groupKey:null,event:null,eventKey:null,folder:null,folderKey:null,policy:null,policyKey:null,member:0,err:null};var home=xrpc("getNode",{path:""});if(!home||!home.node){out.err="root not found";return JSON.stringify(out);}var t=Date.now();var CONTENT=[{type:"heading-one",children:[{text:"E2E Suite"}]},{type:"paragraph",children:[{text:"Hermetic fixture content for the in-context checks."}]},{type:"heading-two",children:[{text:"Section"}]},{type:"paragraph",children:[{text:"Body text so the landing view renders a populated document card."}]}];var g=xrpc("createContext",null,{kind:"group",name:"E2E Suite "+t,parent_id:home.node.id});if(!g){out.err="group create failed";return JSON.stringify(out);}out.group=g.id;out.groupKey=g.path;xrpc("updateContext",null,{id:g.id,content:CONTENT});var ev=xrpc("createContext",null,{kind:"event",name:"E2E Event "+t,parent_id:g.id});if(ev){out.event=ev.id;out.eventKey=ev.path.split("/").pop();}else{out.err="event create failed";}var f=xrpc("createDocument",null,{context_id:g.id,parent_id:g.id,kind:"folder",title:"E2E Docs"});if(!f){out.err=(out.err?out.err+"; ":"")+"folder create failed";return JSON.stringify(out);}out.folder=f.id;out.folderKey=f.slug;var pl=xrpc("createDocument",null,{context_id:g.id,parent_id:f.id,kind:"policy",title:"E2E policy"});if(pl){out.policy=pl.id;out.policyKey=pl.slug;}else{out.err=(out.err?out.err+"; ":"")+"policy create failed";}var mem=xrpc("inviteMembers",null,{context_id:g.id,invites:[{name:"E2E Member",email:"e2e-member-"+t+"@example.com"}]});out.member=mem?mem.inserted:0;return JSON.stringify(out);') let setup_js = (if (on-appview) { $setup_appview_js } else { $setup_js }) let setup = (try { wd-execute $session_id $setup_js | from json } catch { {group: null, groupKey: null, event: null, eventKey: null, folder: null, folderKey: null, policy: null, policyKey: null, err: "setup exec failed"} })
mut hermetic_gid = "" mut sel_ctx = "" if ($setup.group | is-not-empty) and ($setup.folder | is-not-empty) { $hermetic_gid = $setup.group $sel_ctx = $"/($setup.groupKey)" log-ok "hermetic group + folder + policy created"; $p = $p + 1 if ($setup.event | is-not-empty) { log-ok "event nested inside the hermetic group"; $p = $p + 1 } else { log-fail $"could not nest an event in the hermetic group: ($setup.err)"; $fl = $fl + 1 } if (($setup.member | default 0) >= 1) { log-ok "member roster seeded in the hermetic group"; $p = $p + 1 } else { log-fail $"could not seed a member in the hermetic group: ($setup.err)"; $fl = $fl + 1 } wd-navigate $session_id $"(base-url)($sel_ctx)" sleep 1500ms } else { # Fallback: open the first pre-existing context that actually has content, so # the in-context checks run against a populated node. Many groups are empty; # blindly clicking the first avatar can land on an empty one. Click each # context's list-item until the view shows folder children. log-warn $"hermetic setup failed \(($setup.err)) — falling back to an existing context" let n_ctx_str = (wd-execute $session_id 'return String(document.querySelectorAll(".nav-rail-tree .avatar.secondary").length)') let n_ctx = (try { $n_ctx_str | into int } catch { 0 }) if $n_ctx == 0 { log-warn "no context to open — skipping in-context checks" return { passed: $p, failed: $fl } } mut navigated = false mut ci = 0 let max_try = ([$n_ctx 8] | math min) while (not $navigated) and ($ci < $max_try) { # Build the click JS with plain-string concat: a $"..." interpolation # would try to evaluate the literal JS parens. let click_js = ("var xs=document.querySelectorAll('.nav-rail-tree .avatar.secondary'); var e=xs[" + ($ci | into string) + "]; if(e){e.closest('.list-item').click(); return 'clicked'} return 'none'") wd-execute $session_id $click_js | ignore mut moved = false for _ in 1..12 { let path = (wd-execute $session_id 'return location.pathname') if ($path != null) and ($path != "/") { $moved = true; break } sleep 300ms } if $moved { sleep 1500ms let populated = (wd-execute $session_id 'return document.querySelector("#main .folder-tile, #main .list-link")?"y":"n"') if $populated == "y" { $navigated = true } else { # Empty context: back to the home list and try the next one. wd-navigate $session_id $"(base-url)/" sleep 900ms } } $ci = $ci + 1 } if not $navigated { log-warn "no populated context found — skipping in-context checks" return { passed: $p, failed: $fl } } log-ok "navigated into a populated context"; $p = $p + 1 # Remember this context's top-level path; later sections re-enter it rather # than trust wherever earlier breadcrumb navigation left the location. $sel_ctx = (wd-execute $session_id 'return "/"+location.pathname.split("/")[1]') } if ($sel_ctx | is-empty) { log-warn "no context available — skipping in-context checks" return { passed: $p, failed: $fl } } sleep 1sec
let r = (assert-exists $session_id "context view renders a card" "#main .card" -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (check-contrast $session_id "context view (drawer + app rail + bar)" $p $fl); $p = $r.passed; $fl = $r.failed capture-shots $session_id "context"
# Medium-width expandable rail: capture collapsed then (menu-toggled) expanded, # so the rail-expansion motion can be reviewed. if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-window-rect $session_id 980 900 sleep 700ms wd-screenshot $session_id ($dir | path join "rail-med-collapsed.png") wd-execute $session_id 'var b=document.querySelector(".nav-rail-header .btn-icon"); if(b)b.click(); return 1' | ignore sleep 800ms wd-screenshot $session_id ($dir | path join "rail-med-expanded.png") wd-execute $session_id 'var b=document.querySelector(".nav-rail-header .btn-icon"); if(b)b.click(); return 1' | ignore wd-window-rect $session_id 1280 900 sleep 400ms }
# Extra-large: the tools sheet DOCKS as a permanent right-side pane (no trigger, # always visible), and the content column is capped near A4 and centred rather # than stretching the full pane width. wd-window-rect $session_id 1728 1000 sleep 800ms # Measure the gap against documentElement.clientWidth (the layout viewport, # EXCLUDING the scrollbar) — window.innerWidth includes the scrollbar, so on # engines that reserve a classic scrollbar (Firefox) it over-reports the gap # by the scrollbar width and the docked sheet looks a few px off the edge. let xl = (wd-execute $session_id 'var shell=document.querySelector(".app-shell"); var vw=document.documentElement.clientWidth; var dk=document.querySelector(".tool-sheet.docked"); var vis=0, rightGap=-1; if(dk){var r=dk.getBoundingClientRect(); vis=(r.width>0 && r.right<=vw+1 && r.right>=vw-2)?1:0; rightGap=Math.round(vw-r.right)} var cm=document.querySelector(".content-measure"); var cmw=cm?Math.round(cm.getBoundingClientRect().width):-1; var cap=cm?Math.round(parseFloat(getComputedStyle(cm).maxWidth)):-1; return JSON.stringify({docked: dk?1:0, vis: vis, rightGap: rightGap, toolsAttr: shell?shell.getAttribute("data-tools-docked"):"", measureW: cmw, cap: cap})') let x = ($xl | from json) if ($x.docked == 1) and ($x.vis == 1) and ($x.toolsAttr == "true") { log-ok $"extra-large docks the tools pane on the right, gap=($x.rightGap)px"; $p = $p + 1 } else { log-fail $"extra-large tools not docked: ($xl)"; $fl = $fl + 1 } if ($x.cap > 0) and ($x.measureW <= ($x.cap + 2)) { log-ok $"content column capped at ($x.cap)px, measured ($x.measureW)px"; $p = $p + 1 } else { log-fail $"content column not capped: measured=($x.measureW), cap=($x.cap)"; $fl = $fl + 1 } if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-screenshot $session_id ($dir | path join "tools-docked-xl.png") } wd-window-rect $session_id 1280 900 sleep 400ms
# Compact mobile drawer: open it and verify the top is the current context node # (with a trailing close button) and that the redundant "Home" entries are gone # (one used to sit in the header, one in the body list). wd-window-rect $session_id 390 844 sleep 600ms wd-execute $session_id 'var b=document.querySelector(".top-app-bar .btn-icon"); if(b)b.click(); return 1' | ignore sleep 700ms let dstate = (wd-execute $session_id 'var dr=document.querySelector(".nav-drawer.open"); if(!dr) return "noopen"; var hdrBar=dr.querySelector(".nav-drawer-header .drawer-context-bar .drawer-context-name"); var close=dr.querySelector(".nav-drawer-header button")?1:0; var mh=dr.querySelector(".drawer-mobile-home")?1:0; var bodyBar=dr.querySelector(".list .drawer-context-bar"); var bodyShown=bodyBar?(getComputedStyle(bodyBar).display!="none"?1:0):0; var homeCount=Array.prototype.filter.call(dr.querySelectorAll("*"),function(e){return e.children.length==0 && e.innerText && e.innerText.trim()=="Home"}).length; return JSON.stringify({headerCtx: hdrBar?hdrBar.innerText.trim():"", close: close, mobileHome: mh, bodyCtxShown: bodyShown, homeCount: homeCount})') if $dstate == "noopen" { log-warn "mobile drawer did not open — skipping drawer content check" } else { let d = ($dstate | from json) if ($d.headerCtx != "") and ($d.headerCtx != "Home") and ($d.close == 1) { log-ok $"mobile drawer top is the context '($d.headerCtx)' with a close button"; $p = $p + 1 } else { log-fail $"mobile drawer header wrong: ctx='($d.headerCtx)', close=($d.close)"; $fl = $fl + 1 } if ($d.mobileHome == 0) and ($d.bodyCtxShown == 0) and ($d.homeCount == 0) { log-ok "mobile drawer has no redundant Home entries"; $p = $p + 1 } else { log-fail $"mobile drawer has stray Home: mobileHome=($d.mobileHome), bodyCtxShown=($d.bodyCtxShown), homeCount=($d.homeCount)"; $fl = $fl + 1 } } if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-screenshot $session_id ($dir | path join "mobile-drawer.png") } wd-execute $session_id 'var s=document.querySelector(".nav-drawer-scrim"); if(s)s.click(); return 1' | ignore wd-window-rect $session_id 1280 900 sleep 400ms
# Compact: the FAB is the tools-sheet trigger (not add-content). It carries the # "bolt" icon and opens the bottom sheet whenever the sheet is not docked. # Guards the FAB-repurposing change. wd-window-rect $session_id 390 844 sleep 600ms let fabt = (wd-execute $session_id 'var f=document.querySelector(".fab"); if(!f) return "nofab"; var m=f.querySelector(".material-icons"); return m?m.textContent.trim():""') if $fabt == "nofab" { log-warn "no tools FAB on compact — skipping FAB tools check" } else if $fabt == "bolt" { wd-execute $session_id 'document.querySelector(".fab").click(); return 1' | ignore sleep 600ms let opened = (wd-execute $session_id 'return document.querySelector(".tool-sheet.open")?"y":"n"') if $opened == "y" { log-ok "compact FAB opens the tools sheet"; $p = $p + 1 } else { log-fail "compact FAB did not open the tools sheet"; $fl = $fl + 1 } if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-screenshot $session_id ($dir | path join "mobile-tools-fab.png") } wd-execute $session_id 'var s=document.querySelector(".sheet-scrim.open"); if(s)s.click(); return 1' | ignore sleep 400ms } else { log-fail $"compact FAB has the wrong icon: ($fabt)"; $fl = $fl + 1 } wd-window-rect $session_id 1280 900 sleep 400ms
# Pull-to-refresh must actually REFETCH data, not just animate. Hook fetch to # count GraphQL calls, over-scroll up, and expect fresh calls to fire (the # generalized use_data_resource! makes every view refetch on the bump). let path_b = (wd-execute $session_id 'return location.pathname') let items_b = (wd-execute $session_id 'return String(document.querySelectorAll("#main .folder-tile, #main .list-link, #main .card").length)') # Counted by wrapping fetch, handed on WITH the window as receiver: a client # that calls the global `fetch(..)` bare gave the wrapper no `this`, and the # real fetch then refused every request, emptying the drawer until a reload. wd-execute $session_id "if(!window.__gqlHooked){window.__gqlHooked=1; var of=window.fetch; window.fetch=function(){try{var u=arguments[0]; var s=(typeof u=='string')?u:((u&&u.url)||''); if(s.indexOf('graphql')>=0||s.indexOf('/xrpc/')>=0){window.__gql=(window.__gql||0)+1;}}catch(e){} return of.apply(window,arguments);};} return 'ok'" | ignore sleep 400ms wd-execute $session_id "window.__gql=0; window.scrollTo(0,0); var e=new WheelEvent('wheel',{deltaY:-300,bubbles:true,cancelable:true}); window.dispatchEvent(e); return 1" | ignore mut refetched = false mut rtries = 0 while (not $refetched) and $rtries < 12 { sleep 200ms let c = (wd-execute $session_id "return String(window.__gql||0)") let n = (try { $c | into int } catch { 0 }) if $n > 0 { $refetched = true } $rtries = $rtries + 1 } # Wait for the view to settle back, then diagnose whether the refresh blanked # or navigated the view (it must not). mut settled = false for _ in 1..20 { sleep 200ms let now = (wd-execute $session_id 'return String(document.querySelectorAll("#main .folder-tile, #main .list-link, #main .card").length)') if ((try { $now | into int } catch { 0 }) > 0) { $settled = true; break } } let path_a = (wd-execute $session_id 'return location.pathname') let items_a = (wd-execute $session_id 'return String(document.querySelectorAll("#main .folder-tile, #main .list-link, #main .card").length)') log-info $"PTR diag: path ($path_b) to ($path_a); items ($items_b) to ($items_a); refetched=($refetched) settled=($settled)" if $refetched and $settled and ($path_a == $path_b) { log-ok "pull-to-refresh refetches data and keeps the view"; $p = $p + 1 } else { log-fail "pull-to-refresh disrupted the view"; $fl = $fl + 1 } # Draft (not-submitted, mutable) nodes show a lock badge on their avatar. # Only meaningful where the context actually has draft children. let badge_n = (wd-execute $session_id 'return String(document.querySelectorAll("#main .avatar-badge").length)') if ((try { $badge_n | into int } catch { 0 }) > 0) { log-ok "draft nodes show a not-submitted badge"; $p = $p + 1 } else { log-warn "context has no draft nodes — skipping not-submitted-badge check" } # The app rail only appears inside a context; its presence confirms the # in-context layout (and that its icons rendered). let r = (assert-count $session_id "app rail shown in context" ".nav-rail-item" 1 -p $p -f $fl); $p = $r.passed; $fl = $r.failed # The app rail sits to the LEFT of the side panel (rail | drawer | content). let layout = (wd-execute $session_id 'var rl=document.querySelector(".nav-rail"),dr=document.querySelector(".nav-rail-tree"); if(!rl||!dr) return "missing"; return JSON.stringify({rail:Math.round(rl.getBoundingClientRect().left), drawer:Math.round(dr.getBoundingClientRect().left)})') let ok = (try { let j = ($layout | from json); ($j.rail <= $j.drawer) and ($j.rail < 10) } catch { false }) if $ok { log-ok $"nav rail is at the leading edge, tree nested within ($layout)"; $p = $p + 1 } else { log-fail $"nav rail not at the leading edge: ($layout)"; $fl = $fl + 1 } # The drawer must have swapped the home list for the node tree: the # "Groups" home heading is gone once inside a context. let drawer_txt = (wd-execute $session_id 'return (document.querySelector(".nav-rail-tree")||{innerText:""}).innerText') if ($drawer_txt | describe) == "string" and (not ($drawer_txt | str contains "Groups")) { log-ok "drawer switched to node tree (home list gone)"; $p = $p + 1 } else { log-fail "drawer still shows the home list inside a context"; $fl = $fl + 1 } # The tree/drawer's top entry is the current context (not "Home"), and the old # redundant mobile-Home fallback link is gone (Home is on the app rail / bottom # nav bar instead). let dsw = (wd-execute $session_id 'var d=document.querySelector(".nav-rail-tree"); if(!d) return "nodrawer"; var mh=d.querySelector(".drawer-mobile-home")?1:0; var cn=d.querySelector(".drawer-context-bar .drawer-context-name"); return JSON.stringify({mobileHome:mh, contextText: cn?cn.innerText.trim():""})') if $dsw == "nodrawer" { log-warn "no drawer to check the context switch" } else { let m = ($dsw | from json) if ($m.contextText != "Home") and ($m.contextText != "") and ($m.mobileHome == 0) { log-ok $"drawer top is the context bar '($m.contextText)', no redundant Home"; $p = $p + 1 } else { log-fail $"drawer context bar off: text='($m.contextText)', mobileHome=($m.mobileHome)"; $fl = $fl + 1 } } # (The old "drawer context bar aligns with the top panel" check is retired: # in the M3 rail layout the tree + its context bar are nested inside the # navigation rail, not a separate panel aligned to the top app bar.)
# ── Navigate to a child node (regression: PathPage must re-resolve on a # client-side route change between two path pages, not show stale content). let path_before = (wd-execute $session_id 'return location.pathname') let main_before = (wd-execute $session_id 'return (document.getElementById("main")||{innerText:""}).innerText') # The fixture's folder where there is one: its event is a context of its own, # whose drawer starts over at the event and has no row for it to highlight. let clicked = (wd-execute $session_id 'var es=[...document.querySelectorAll("#main .folder-tile, #main .list-link")]; var e=es.find(function(x){return x.textContent.indexOf("E2E Docs")>=0})||es[0]; if(e){e.click(); return "y"} return "n"') if $clicked == "y" { mut deeper = false for _ in 1..($timeout) { let path_now = (wd-execute $session_id 'return location.pathname') if ($path_now != null) and ($path_now != $path_before) { $deeper = true; break } sleep 500ms } sleep 2sec let main_after = (wd-execute $session_id 'return (document.getElementById("main")||{innerText:""}).innerText') if $deeper and ($main_after != $main_before) { log-ok "navigating to a child node updates the view"; $p = $p + 1 # The drawer tree highlights the current node in the path. Waited for: # the tree fetches its levels again after the move. wd-wait-y $session_id 'return document.querySelector(".nav-rail-tree .list-item.selected")?"y":"n"' 8000 | ignore let sel = (wd-execute $session_id 'return document.querySelector(".nav-rail-tree .list-item.selected")?"y":"n"') if $sel == "y" { log-ok "drawer highlights the current node"; $p = $p + 1 } else { let rows = (wd-execute $session_id 'var t=document.querySelector(".nav-rail-tree");return JSON.stringify({path:location.pathname,rows:[...document.querySelectorAll(".nav-rail-tree .list-item")].map(function(r){return r.className}),rail:(document.querySelector(".nav-rail")||{}).className,tree:t?t.innerHTML.replace(/\s+/g," ").slice(0,500):null})') log-fail $"drawer does not highlight the current node: ($rows)"; $fl = $fl + 1 } } else { log-fail "child navigation did not update the view (stale PathPage)"; $fl = $fl + 1 } } else { log-warn "context has no folder-item children — skipping child-nav check" }
# ── Breadcrumb up-navigation re-resolves the view (regression) ─────── # Clicking a breadcrumb changes the URL to an ancestor path; the main view # must re-render to that ancestor, not keep showing the deeper node. This # broke when PathPage relied on router prop propagation between two PathPage # routes instead of subscribing to the route itself. let bc_path_before = (wd-execute $session_id 'return location.pathname') let bc_main_before = (wd-execute $session_id 'return (document.getElementById("main")||{innerText:""}).innerText') # Click a PARENT crumb (every crumb is now a link incl. the current node, so # click the second-to-last to actually navigate up a level). let bc_clicked = (wd-execute $session_id 'var a=[...document.querySelectorAll(".breadcrumbs a")]; if(a.length>1){a[a.length-2].click(); return "y"} return "n"') if $bc_clicked == "y" { mut bc_nav = false for _ in 1..($timeout) { let now = (wd-execute $session_id 'return location.pathname') if ($now != null) and ($now != $bc_path_before) { $bc_nav = true; break } sleep 500ms } sleep 2sec let bc_main_after = (wd-execute $session_id 'return (document.getElementById("main")||{innerText:""}).innerText') if $bc_nav and ($bc_main_after != $bc_main_before) { log-ok "breadcrumb navigation updates the view"; $p = $p + 1 } else { log-fail "breadcrumb changed the URL but not the view (stale PathPage)"; $fl = $fl + 1 } } else { log-warn "not enough breadcrumb links to test up-navigation" }
# Breadcrumbs show a mime avatar per crumb (home + each segment). let r = (assert-count $session_id "breadcrumbs render avatars" ".breadcrumbs .crumb-avatar" 1 -p $p -f $fl); $p = $r.passed; $fl = $r.failed
# The current breadcrumb doubles as a page table-of-contents trigger: clicking # it opens a popover listing the page's headings. Force a desktop width so the # bar sits at the top (the alignment check below expects the popover BELOW it). wd-window-rect $session_id 1280 900 sleep 300ms wd-navigate $session_id $"(base-url)($sel_ctx)" sleep 800ms wd-execute $session_id 'var c=document.querySelector(".breadcrumbs .crumb-toc .crumb-link"); if(c)c.click(); return 1' | ignore sleep 400ms let toc_open = (wd-execute $session_id 'return document.querySelector(".toc-popover")?"y":"n"') if $toc_open == "y" { log-ok "page TOC opens from the current breadcrumb"; $p = $p + 1 } else { log-fail "page TOC did not open from the breadcrumb"; $fl = $fl + 1 } # The popover must anchor UNDER the trigger crumb, not pin to the far left (a # regression once the nav rail + tree pane push the breadcrumbs bar right). if $toc_open == "y" { let algn = (wd-execute $session_id 'var p=document.querySelector(".toc-popover"),c=document.querySelector(".breadcrumbs .crumb-toc");if(!p||!c)return "nomatch";var pr=p.getBoundingClientRect(),cr=c.getBoundingClientRect();return JSON.stringify({dx:Math.round(Math.abs(pr.left-cr.left)),below:pr.top>=cr.top?1:0})') if $algn == "nomatch" { log-warn "could not measure TOC popover alignment" } else { let aj = ($algn | from json) if ($aj.dx <= 24) and ($aj.below == 1) { log-ok $"TOC popover anchors under the crumb \(dx=($aj.dx)px)"; $p = $p + 1 } else { log-fail $"TOC popover mispositioned \(dx=($aj.dx)px, below=($aj.below))"; $fl = $fl + 1 } } } # Each entry carries a header icon/letter (not a dull line of text), and pure # divider headings (dashes) are filtered out. if $toc_open == "y" { let toc_rich = (wd-execute $session_id 'var items=[...document.querySelectorAll(".toc-popover .toc-item")];if(!items.length)return "empty";var withIcon=items.filter(function(it){return it.querySelector(".toc-icon,.toc-letter")}).length;var dashOnly=items.filter(function(it){var t=(it.querySelector(".toc-text")||{}).textContent||"";return t.trim()&&!/[a-z0-9æøå]/i.test(t)}).length;return JSON.stringify({items:items.length,withIcon:withIcon,dashOnly:dashOnly})') if $toc_rich == "empty" { log-warn "TOC popover has no entries here — skipping icon check" } else { let tr = ($toc_rich | from json) if ($tr.withIcon == $tr.items) and ($tr.dashOnly == 0) { log-ok $"TOC entries carry header icons \(($tr.withIcon)/($tr.items), no divider rows)"; $p = $p + 1 } else { log-fail $"TOC entries missing icons or show dividers: ($toc_rich)"; $fl = $fl + 1 } } } wd-execute $session_id 'var s=document.querySelector(".toc-scrim"); if(s)s.click(); return 1' | ignore # A TOC jump to a card title must land the card's TOP below the sticky bar, not # tuck it underneath — enforced by scroll-margin-top on the scroll targets. let sm = (wd-execute $session_id 'var c=document.querySelector("#main-content .card");if(!c)return "nocard";return String(Math.round(parseFloat(getComputedStyle(c).scrollMarginTop)||0))') if $sm == "nocard" { log-warn "no card to check TOC scroll-margin — skipping" } else if (($sm | into int) >= 64) { log-ok $"TOC scroll target clears the sticky bar \(scroll-margin-top ($sm)px)"; $p = $p + 1 } else { log-fail $"TOC scroll target would tuck under the bar \(scroll-margin-top ($sm)px)"; $fl = $fl + 1 }
# ── App rail switches apps via the ?app= route query (client-side) ──── # Go back to the SELECTED context (breadcrumb navigation above may have left # us at home), then click the Vote rail item and confirm the URL gains # ?app=vote and the vote view renders. Click via JS on the anchor (WebDriver's # click can land on an inner span the router doesn't intercept). let ctx_path = $sel_ctx wd-navigate $session_id $"(base-url)($ctx_path)" if (wd-wait-for-element $session_id ".nav-rail a" 15) { let clicked_vote = (wd-execute $session_id 'var a=[...document.querySelectorAll(".nav-rail a")].find(function(x){return (x.getAttribute("href")||"").includes("app=vote")}); if(a){a.click(); return "y"} return "n"') if $clicked_vote == "y" { mut switched = false for _ in 1..($timeout) { let s = (wd-execute $session_id 'return location.search') if ($s | default "" | str contains "app=vote") { $switched = true; break } sleep 500ms } sleep 1sec if $switched { log-ok "app rail routes to ?app=vote"; $p = $p + 1 } else { let search = (wd-execute $session_id 'return location.search') log-fail $"app rail did not set ?app=vote; got: ($search)"; $fl = $fl + 1 } let r = (assert-exists $session_id "vote app renders" "#main .card" -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (check-contrast $session_id "vote app (active app-rail highlight)" $p $fl); $p = $r.passed; $fl = $r.failed # The open app is its OWN trailing breadcrumb (a labelled, clickable # step) rather than a small overlay badge on the node avatar. let appcrumb = (wd-execute $session_id 'var links=[...document.querySelectorAll(".breadcrumbs .crumb .crumb-link")]; var last=links[links.length-1]; var href=last?(last.getAttribute("href")||""):""; var nmEl=last?last.querySelector(".crumb-name.open"):null; var badge=document.querySelector(".crumb-app-badge")?1:0; return JSON.stringify({href: href, name: nmEl?nmEl.innerText.trim():"", badge: badge})') let ac = ($appcrumb | from json) if ($ac.href | str contains "app=vote") and ($ac.badge == 0) { log-ok $"open app is its own trailing crumb, name='($ac.name)', no overlay badge"; $p = $p + 1 } else { log-fail $"app crumb missing or wrong: ($appcrumb)"; $fl = $fl + 1 } capture-shots $session_id "vote-app" # Speak app (if this context exposes it): capture it, then return to # the vote app so the downstream vote-app checks keep their state. let has_speak = (wd-execute $session_id 'return document.querySelector(".nav-rail a[href*=\"app=speak\"]")?"y":"n"') if $has_speak == "y" { wd-execute $session_id 'var a=[...document.querySelectorAll(".nav-rail a")].find(function(x){return (x.getAttribute("href")||"").includes("app=speak")}); if(a){a.click()}; return 1' | ignore sleep 800ms capture-shots $session_id "speak-app" wd-execute $session_id 'var a=[...document.querySelectorAll(".nav-rail a")].find(function(x){return (x.getAttribute("href")||"").includes("app=vote")}); if(a){a.click()}; return 1' | ignore sleep 600ms } # The highlighted app must have readable contrast (icon vs its box): # regression for the green-on-green active state. # M3 nav-rail active indicator: a container-tone pill behind the icon. # Two guards: the icon must be readable ON the pill (luminance ratio, # the green-on-green regression), and the pill must be distinguishable # FROM the rail (colour DISTANCE, since a tonal pill differs from the # neutral rail by hue/chroma, not luminance). let arc = (wd-execute $session_id 'function L(c){var a=c.map(function(v){v/=255;return v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4)});return 0.2126*a[0]+0.7152*a[1]+0.0722*a[2]} function P(s){var m=s.match(/[0-9.]+/g);return [+m[0],+m[1],+m[2]]} function R(x,y){var p=L(P(x)),q=L(P(y)),h=Math.max(p,q),l=Math.min(p,q);return (h+0.05)/(l+0.05)} function D(x,y){var a=P(x),b=P(y);return Math.round(Math.sqrt((a[0]-b[0])*(a[0]-b[0])+(a[1]-b[1])*(a[1]-b[1])+(a[2]-b[2])*(a[2]-b[2])))} var el=document.querySelector(".nav-rail-item.active"); if(!el) return "noactive"; var pill=el.querySelector(".nav-rail-indicator")||el; var ic=el.querySelector(".material-icons"); var pb=getComputedStyle(pill).backgroundColor; if(pb=="rgba(0, 0, 0, 0)"||pb=="transparent"){pb=getComputedStyle(el).backgroundColor;} var icc=ic?getComputedStyle(ic).color:getComputedStyle(el).color; var rail=document.querySelector(".nav-rail"); var rb=rail?getComputedStyle(rail).backgroundColor:"rgb(255,255,255)"; return JSON.stringify({readable:+R(pb,icc).toFixed(2), distinct:D(pb,rb)})') if $arc == "noactive" { log-warn "no active app-rail item to contrast-check" } else { let m = ($arc | from json) if ($m.readable >= 3.0) and ($m.distinct >= 40) { log-ok $"active app indicator: icon ($m.readable):1 on the pill, colour-distance ($m.distinct) from rail"; $p = $p + 1 } else { log-fail $"active app indicator weak: readable ($m.readable):1, distinct ($m.distinct)"; $fl = $fl + 1 } } # The open app is represented in the breadcrumb as its own trailing # crumb carrying the app icon (the overlay badge was retired). sleep 1sec let crumbapp = (wd-execute $session_id 'var links=[...document.querySelectorAll(".top-app-bar .breadcrumbs .crumb .crumb-link")]; var last=links[links.length-1]; var ic=last?last.querySelector(".material-icons"):null; var noBadge=document.querySelector(".crumb-app-badge")?0:1; return JSON.stringify({icon: ic?ic.textContent.trim():"", trailingHref: last?(last.getAttribute("href")||""):"", noBadge: noBadge})') let cba = ($crumbapp | from json) if ($cba.icon == "how_to_vote") and ($cba.trailingHref | str contains "app=vote") and ($cba.noBadge == 1) { log-ok "open app shows as a trailing breadcrumb with its icon"; $p = $p + 1 } else { log-fail $"app not represented as a trailing crumb: ($crumbapp)"; $fl = $fl + 1 } # Hovering a collapsed crumb reveals its name. This is now pure CSS # (`.crumb:hover`), so it only responds to a REAL pointer position — a # synthetic mouseenter would not trigger :hover. Use WebDriver Actions to # actually move the mouse onto the first (collapsed) crumb and assert its # name expands (computed max-width goes from 0 to non-zero). let nm_before = (wd-execute $session_id 'var c=document.querySelector(".top-app-bar .breadcrumbs .crumb"); var nm=c?c.querySelector(".crumb-name"):null; return nm?getComputedStyle(nm).maxWidth:"none"') let crumb_eid = (wd-find $session_id ".top-app-bar .breadcrumbs .crumb") if ($crumb_eid | is-not-empty) { let acts = ({ actions: [{ type: "pointer", id: "mouse", parameters: { pointerType: "mouse" }, actions: [{ type: "pointerMove", duration: 50, origin: "viewport", x: 3, y: 3 }, { type: "pointerMove", duration: 120, origin: { "element-6066-11e4-a52e-4f735466cecf": $crumb_eid }, x: 2, y: 2 }] }] } | to json) wd-post $"/session/($session_id)/actions" $acts | ignore sleep 500ms let nm_after = (wd-execute $session_id 'var c=document.querySelector(".top-app-bar .breadcrumbs .crumb"); var nm=c?c.querySelector(".crumb-name"):null; return nm?getComputedStyle(nm).maxWidth:"none"') let w_before = (try { $nm_before | str replace "px" "" | into float } catch { -1.0 }) let w_after = (try { $nm_after | str replace "px" "" | into float } catch { -1.0 }) if ($w_before == 0.0) and ($w_after > 0.0) { log-ok $"real pointer hover expands the crumb name, max-width ($nm_before)->($nm_after)"; $p = $p + 1 } else { log-fail $"crumb hover-expand broken: before=($nm_before) after=($nm_after)"; $fl = $fl + 1 } # Move the pointer away so later tests aren't left hovering. wd-post $"/session/($session_id)/actions" ({ actions: [{ type: "pointer", id: "mouse", parameters: { pointerType: "mouse" }, actions: [{ type: "pointerMove", duration: 30, origin: "viewport", x: 3, y: 400 }] }] } | to json) | ignore sleep 200ms } # Only the ready apps (folder/speak/vote, plus member + console for # this context's owner) are in the rail; the rest (graph/social/...) # are hidden until ready. let rail = (wd-execute $session_id 'return JSON.stringify({vote:document.querySelector(".nav-rail a[href*=\"app=vote\"]")?1:0, speak:document.querySelector(".nav-rail a[href*=\"app=speak\"]")?1:0, member:document.querySelector(".nav-rail a[href*=\"app=member\"]")?1:0, admin:document.querySelector(".nav-rail a[href*=\"app=admin\"]")?1:0, graph:document.querySelector(".nav-rail a[href*=\"app=graph\"]")?1:0, social:document.querySelector(".nav-rail a[href*=\"app=social\"]")?1:0})') let ok = (try { let j = ($rail | from json); ($j.vote == 1) and ($j.speak == 1) and ($j.member == 1) and ($j.admin == 1) and ($j.graph == 0) and ($j.social == 0) } catch { false }) if $ok { log-ok "app rail shows the ready apps + owner surfaces (owned context)"; $p = $p + 1 } else { log-fail $"app rail set unexpected: ($rail)"; $fl = $fl + 1 } } else { log-warn "no vote rail item — skipping app-switch check" } } else { log-warn "app rail not found — skipping app-switch check" }
# ── Owner-gated rail apps: on a context the account does NOT own, the # Members and Console entries are absent (both showed on the owned # hermetic context just above). Uses any plain (non-owner) membership of # the account; skips when it owns everything it can see. ── # Only a TOP-LEVEL context works here: the check navigates to /(key), which # resolves by walking root children, so a nested event's key would 404. let nonown_key = (try { wd-execute $session_id ($gql + 'var rt=gql("query{nodes(where:{mimeId:{_eq:\"wiki/home\"}}){id}}",{});var ROOT=null;try{ROOT=rt.data.nodes[0].id}catch(e){}var r=gql("query{members(where:{owner:{_eq:false},accepted:{_eq:true}}){parent{key mimeId parentId}}}",{});var k="none";try{var ms=r.data.members.filter(function(m){return m.parent&&m.parent.parentId==ROOT&&(m.parent.mimeId=="wiki/group"||m.parent.mimeId=="wiki/event")&&/^[a-zA-Z0-9_-]+$/.test(m.parent.key)});if(ms.length)k=ms[0].parent.key}catch(e){}return k;') } catch { "none" }) if ($nonown_key == "none") or ($nonown_key == null) or ($nonown_key | is-empty) { log-warn "no non-owned context visible — skipping owner-gated rail check" } else { wd-navigate $session_id $"(base-url)/($nonown_key)" wd-wait-y $session_id 'return document.querySelector(".nav-rail a[href*=\"app=vote\"]")?"y":"n"' 8000 | ignore sleep 800ms let gated = (wd-execute $session_id 'return JSON.stringify({vote:document.querySelector(".nav-rail a[href*=\"app=vote\"]")?1:0, member:document.querySelector(".nav-rail a[href*=\"app=member\"]")?1:0, admin:document.querySelector(".nav-rail a[href*=\"app=admin\"]")?1:0})') let gok = (try { let j = ($gated | from json); ($j.vote == 1) and ($j.member == 0) and ($j.admin == 0) } catch { false }) if $gok { log-ok $"members + console hidden on a non-owned context \(/($nonown_key))"; $p = $p + 1 } else { log-fail $"owner-gated rail apps wrong for a non-owner: ($gated) at /($nonown_key)"; $fl = $fl + 1 } }
# ── Breadcrumbs start at the context (nearest group/event), not the root ── wd-navigate $session_id $"(base-url)($ctx_path)" wd-wait-y $session_id 'return document.querySelector(".breadcrumbs .crumb")?"y":"n"' 3000 | ignore # At a top-level context the trail begins with the context itself, so there # is no Home crumb (a breadcrumb link to "/"). let home_crumb = (wd-execute $session_id 'return document.querySelector(".top-app-bar .breadcrumbs a[href=\"/\"]")?"y":"n"') let crumbs = (wd-execute $session_id 'return document.querySelectorAll(".top-app-bar .breadcrumbs .crumb").length') let ok = (try { ($home_crumb == "n") and (($crumbs | into int) >= 1) } catch { false }) if $ok { log-ok $"breadcrumbs start at the context, no home crumb, crumbs=($crumbs)"; $p = $p + 1 } else { # Context shape varies (a first-segment path may resolve to a parent); # informational rather than a hard failure. log-warn $"breadcrumbs did not start at context here: home=($home_crumb) crumbs=($crumbs)" }
# ── New apps render via ?app= (graph / program / social / profile / cow) ── # Each should mount a card (or its signature element) without trapping. for app in [ { q: "cow", sel: ".cowsay", name: "cow app renders" } { q: "graph", sel: "#main .card", name: "graph app renders" } { q: "program", sel: "#main .card", name: "program app renders" } { q: "social", sel: "#main input", name: "social app renders" } { q: "profile", sel: "#main .card", name: "profile app renders" } { q: "parent", sel: "#main .card", name: "parent (missing-parents) app renders" } ] { wd-navigate $session_id $"(base-url)($ctx_path)?app=($app.q)" if (wd-wait-for-element $session_id $app.sel 15) { let r = (assert-exists $session_id $app.name $app.sel -p $p -f $fl); $p = $r.passed; $fl = $r.failed } else { log-fail $"($app.name): ($app.sel) not found"; $fl = $fl + 1 } }
# ── Member table: the M3 Expressive roster (search + filter chips + paging, # built for 1000+ members) mounts its toolbar/footer, and a filter chip # toggles its selected state. Skips gracefully if the context has none. ── wd-navigate $session_id $"(base-url)($ctx_path)?app=member" if (wd-wait-for-element $session_id "#main .paginated-table" 15) { let r = (assert-exists $session_id "member table renders" "#main .paginated-table" -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (assert-exists $session_id "member table has a search field" "#main .paginated-table .search-field input" -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (assert-count $session_id "member table has filter chips" "#main .m3-filter-chip" 3 -p $p -f $fl); $p = $r.passed; $fl = $r.failed let r = (assert-exists $session_id "member table has a pagination footer" "#main .paginated-table-footer" -p $p -f $fl); $p = $r.passed; $fl = $r.failed # Rows must actually load with a non-zero total (regression guard: the count # query used a non-existent members_aggregate, whose validation error # emptied the whole page). let mstats = (wd-execute $session_id 'var rows=document.querySelectorAll("#main .m3-data-table tbody tr").length; var c=document.querySelector("#main .paginated-count"); var m=c?(c.innerText.match(/(\d+)\s*$/)||[])[1]:null; return JSON.stringify({rows: rows, total: m?parseInt(m):0})') let ms = ($mstats | from json) if ($ms.rows > 0) and ($ms.total > 0) { log-ok $"member table loaded ($ms.rows) rows, total ($ms.total)"; $p = $p + 1 } else { log-fail $"member table fetched no members: ($mstats)"; $fl = $fl + 1 } # Screenshot the populated table (default "all" filter) BEFORE the filter # toggle below narrows it (the test group's members aren't owners). capture-shots $session_id "member" # Clicking a not-yet-selected filter chip selects it (M3 filter-chip toggle). wd-execute $session_id 'var c=[...document.querySelectorAll("#main .m3-filter-chip")].find(x=>!x.classList.contains("selected")); if(c)c.click(); return 1' | ignore sleep 500ms let sel = (wd-execute $session_id 'return document.querySelectorAll("#main .m3-filter-chip.selected").length') if (($sel | into int) >= 1) { log-ok "filter chip toggles selected state"; $p = $p + 1 } else { log-fail "filter chip did not select"; $fl = $fl + 1 } # Remove-member confirm dialog must actually open (non-destructive: we Cancel, # never Delete a real member). Reset to "all" first so there are rows. wd-execute $session_id 'var a=[...document.querySelectorAll("#main .m3-filter-chip")].find(x=>x.textContent.trim().toLowerCase().startsWith("all")); if(a)a.click(); return 1' | ignore sleep 700ms wd-execute $session_id 'var b=[...document.querySelectorAll("#main .m3-data-table tbody tr button")].find(x=>{var m=x.querySelector(".material-icons"); return m&&m.textContent.trim()=="person_remove"}); if(b)b.click(); return 1' | ignore sleep 600ms let dlg = (wd-execute $session_id 'var d=document.querySelector(".m3-dialog"); var act=document.querySelector(".m3-dialog-actions .btn-primary")?1:0; return JSON.stringify({dialog: d?1:0, action: act})') let dj = ($dlg | from json) if ($dj.dialog == 1) and ($dj.action == 1) { log-ok "member remove opens the confirm dialog"; $p = $p + 1 } else { log-fail $"member remove dialog did not open: ($dlg)"; $fl = $fl + 1 } # Cancel — never delete a real member in the harness. wd-execute $session_id 'var c=document.querySelector(".m3-dialog-actions .btn-outlined"); if(c)c.click(); return 1' | ignore sleep 300ms } else { log-warn "member table not found (context may have no members) — skipping" }
# ── Mobile shell (390px): the search/breadcrumb bar sits at the BOTTOM (thumb # reach) as an Expressive search pill, above the app navigation bar. ── wd-window-rect $session_id 390 844 sleep 500ms wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec if (servo-skip "mobile shell layout (needs a compact viewport)") { # Servo ignores the resize to 390px, so the compact/mobile shell never # activates — an engine gap, not a layout bug (verified under Firefox). } else { let mob = (wd-execute $session_id 'var bar=document.querySelector(".top-app-bar"); if(!bar) return "nobar"; var r=bar.getBoundingClientRect(); var pill=document.querySelector(".top-app-bar .expressive-search")?1:0; var nav=document.querySelectorAll(".nav-bar .nav-bar-item").length; return JSON.stringify({barTop:Math.round(r.top), vh:window.innerHeight, pill:pill, navItems:nav})') let ok = (try { let j = ($mob | from json); ($j.barTop > ($j.vh / 2)) and ($j.pill == 1) and ($j.navItems >= 1) } catch { false }) if $ok { log-ok $"mobile: search bar at bottom, expressive pill, nav bar ($mob)"; $p = $p + 1 } else { log-fail $"mobile shell layout off: ($mob)"; $fl = $fl + 1 } } wd-window-rect $session_id 1280 900 sleep 400ms
# The muted-text refactor relies on Dioxus MERGING two `class:` attributes # (a base body-* class + text-muted), not last-wins. A profile paragraph must # carry BOTH classes, confirming the base class was not silently dropped. # A person has one page, /profile/:id, their own id included (`?app=profile` # is gone: it made two pages of one person). let own_id = (wd-execute $session_id 'try{return JSON.parse(localStorage.getItem("wiki_session")).user.id}catch(e){return ""}') let own_profile = $"(base-url)/profile/($own_id)" wd-navigate $session_id $own_profile if (wd-wait-for-element $session_id "#main .text-muted" 15) { let merged = (wd-execute $session_id 'return document.querySelector("#main .body-medium.text-muted, #main .body-small.text-muted")?"y":"n"') if $merged == "y" { log-ok "class attributes merge (base + text-muted)"; $p = $p + 1 } else { log-fail "class merge dropped the base class" ; $fl = $fl + 1 } } else { log-warn "no .text-muted on profile — skipping class-merge check" } # The profile is the person: who they are, and what they wrote. Where they # belong is the home's to list, in separate Groups and Events. sleep 500ms let on_profile = (wd-execute $session_id 'return [...document.querySelectorAll("#main .card-header h3")].some(function(h){return h.textContent.trim()=="Your contributions"})?"y":"n"') if $on_profile == "y" { log-ok "own /profile/:id renders the profile, with a contributions section"; $p = $p + 1 } else { log-fail "own /profile/:id did not render the profile"; $fl = $fl + 1 } wd-navigate $session_id $"(base-url)/" wd-wait-y $session_id 'return [...document.querySelectorAll("#main .card-header h3")].some(function(h){return h.textContent.trim()=="Groups"})?"y":"n"' 6000 | ignore let sections = (wd-execute $session_id 'var hs=[...document.querySelectorAll("#main .card-header h3")].map(function(h){return h.textContent.trim()}); return JSON.stringify({groups:hs.indexOf("Groups")>=0,events:hs.indexOf("Events")>=0})') let sec = ($sections | from json) if $sec.groups and $sec.events { log-ok "home has separate Groups and Events lists"; $p = $p + 1 } else { log-fail $"home missing group/event split: ($sections)"; $fl = $fl + 1 } wd-navigate $session_id $own_profile sleep 800ms # Profile lists reveal more incrementally via "Show more". let more = (wd-execute $session_id 'var b=[...document.querySelectorAll("#main .list-expand-toggle")].find(function(x){return x.textContent.indexOf("Show more")>=0}); if(!b) return "notoggle"; var before=document.querySelectorAll("#main .list-item, #main .list-link").length; b.click(); return JSON.stringify({before:before})') if $more == "notoggle" { log-warn "no show-more on profile (account has few items) — skipping" } else { sleep 400ms let after = (wd-execute $session_id 'return document.querySelectorAll("#main .list-item, #main .list-link").length') let b = (($more | from json).before) if ($after > $b) { log-ok $"profile show-more reveals more \(($b) -> ($after))"; $p = $p + 1 } else { log-fail $"profile show-more did not reveal more \(($b) -> ($after))"; $fl = $fl + 1 } }
# ── Search resolves a result to its full node path (not just the key) ──── wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec wd-execute $session_id 'var b=[...document.querySelectorAll(".bar button.btn-icon")].find(x=>{var m=x.querySelector(".material-icons"); return m&&m.textContent=="search"}); if(b)b.click(); return 1' | ignore sleep 500ms let sinput = (try { wd-find $session_id ".bar input" } catch { "" }) if ($sinput | is-not-empty) and $sinput != "null" { wd-send-keys $session_id $sinput "e" sleep 2sec let has_res = (wd-execute $session_id 'return document.querySelector(".search-results .list-item")?"y":"n"') if $has_res == "y" { let sp_before = (wd-execute $session_id 'return location.pathname') wd-execute $session_id 'var it=document.querySelector(".search-results .list-item"); if(it)it.click(); return 1' | ignore mut moved = false for _ in 1..($timeout) { let now = (wd-execute $session_id 'return location.pathname') if ($now != null) and ($now != $sp_before) { $moved = true; break } sleep 500ms } sleep 1sec if $moved { log-ok "search result navigates to its node"; $p = $p + 1 # And the app-less URL has no stray "?" left by the router. let srch = (wd-execute $session_id 'return location.search') if ($srch | default "") == "?" { log-fail "plain URL kept a stray '?'"; $fl = $fl + 1 } else { log-ok "no trailing '?' on app-less URL"; $p = $p + 1 } # A content post (document/policy) shows the nested comment # section with a composer. sleep 1sec let cs = (wd-execute $session_id 'return document.querySelector(".comment-section .comment-input")?"y":"n"') if $cs == "y" { log-ok "comment section renders with a composer"; $p = $p + 1 } else { log-warn "search result is not a content post, skipping comment-section check" } } else { log-fail "search result click did not navigate"; $fl = $fl + 1 } } else { log-warn "no search results — skipping search-nav check" } } else { log-warn "search input not found — skipping search-nav check" }
# ── Folder grid/list view mode is remembered across a reload (#125) ────── wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec let gt = (wd-execute $session_id 'var b=[...document.querySelectorAll("#main .btn-icon")].find(x=>{var m=x.querySelector(".material-icons"); return m&&m.textContent=="grid_view"}); if(b){b.click(); return "y"} return "n"') if $gt == "y" { sleep 1sec let grid_now = (wd-execute $session_id 'return document.querySelector(".folder-grid")?"y":"n"') wd-navigate $session_id $"(base-url)($ctx_path)" sleep 2sec let grid_after = (wd-execute $session_id 'return document.querySelector(".folder-grid")?"y":"n"') if ($grid_now == "y") and ($grid_after == "y") { log-ok "folder grid/list choice persists across reload"; $p = $p + 1 } else { log-fail $"folder view not persisted; now=($grid_now) after=($grid_after)"; $fl = $fl + 1 } } else { log-warn "no grid toggle (folder has <=1 child) — skipping persistence check" }
# ── Saving in an app returns to the node's non-app view ────────────────── # Open the sort app and save (re-persists the same order); it should redirect # back to the folder (?app=sort dropped from the URL). wd-navigate $session_id $"(base-url)($ctx_path)?app=sort" if (wd-wait-for-element $session_id "#main .btn-primary" 15) { wd-execute $session_id 'var b=document.querySelector("#main .btn-primary"); if(b)b.click(); return 1' | ignore mut redirected = false for _ in 1..($timeout) { let s = (wd-execute $session_id 'return location.search') if not ($s | default "" | str contains "app=sort") { $redirected = true; break } sleep 500ms } if $redirected { log-ok "saving the sort app returns to the node view"; $p = $p + 1 } else { log-fail "sort save did not redirect off ?app=sort"; $fl = $fl + 1 } } else { log-warn "sort app save button not found — skipping redirect check" }
# ── The add-content button (in the Items card header) opens a modal ────── # On desktop the create action is an in-header .add-action icon button (the FAB # is repurposed as the tools-sheet trigger on compact), not a floating FAB. wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec let addbtn = (wd-execute $session_id 'return document.querySelector("#main .card-header .btn-icon.add-action")?"y":"n"') if $addbtn == "y" { wd-execute $session_id 'document.querySelector("#main .card-header .btn-icon.add-action").click(); return 1' | ignore sleep 1sec let modal = (wd-execute $session_id 'return document.querySelector(".m3-dialog")?"y":"n"') # Cancel (outlined button) — never Add — so no content is created. wd-execute $session_id 'var c=document.querySelector(".m3-dialog .btn-outlined"); if(c)c.click(); return 1' | ignore if $modal == "y" { log-ok "in-header add-action opens the M3 dialog"; $p = $p + 1 } else { log-fail "in-header add-action did not open the dialog"; $fl = $fl + 1 } } else { log-warn "no in-header add-action — skipping add-content check" }
# ── Recursive folder export (.odt), now inside the M3 tools sheet ──────── wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec # The tools sheet has two forms: a FAB-triggered modal (below extra-large) and # a permanent docked side sheet (extra-large — no FAB, always open). The # open/Escape/focus-return checks only apply to the modal; run them when a FAB # is present, otherwise note the docked form. The export action below is # reachable in BOTH forms. let has_fab = (wd-execute $session_id 'return document.querySelector(".fab")?"y":"n"') if $has_fab == "y" { # Open the modal via its bottom-right "bolt" FAB. Focus it first so we can # verify focus returns to it on close (a11y). wd-execute $session_id 'var t=document.querySelector(".fab"); if(t){t.focus(); t.click();} return 1' | ignore sleep 500ms # Screenshot the open sheet (side sheet on desktop, bottom sheet on mobile). capture-shots $session_id "toolsheet" # The sheet must anchor to the VIEWPORT edge, not float inside a card — a # regression guard for the transform-containing-block bug (fixed cards). let sheet_pos = (wd-execute $session_id 'var s=document.querySelector(".tool-sheet.open"); if(!s) return "noopen"; var r=s.getBoundingClientRect(); return JSON.stringify({right:Math.round(document.documentElement.clientWidth-r.right), bottom:Math.round(window.innerHeight-r.bottom)})') let ok = (try { let j = ($sheet_pos | from json); ($j.right <= 2) and ($j.bottom <= 2) } catch { false }) if $ok { log-ok $"tool sheet anchored to the viewport edge ($sheet_pos)"; $p = $p + 1 } else { log-fail $"tool sheet not at viewport edge: ($sheet_pos)"; $fl = $fl + 1 } # Escape closes the sheet (a11y: focus lands inside on open, Esc dismisses). wd-execute $session_id 'var a=document.activeElement||document.querySelector(".tool-sheet.open"); if(a){a.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))}; return 1' | ignore sleep 500ms let closed = (wd-execute $session_id 'return document.querySelector(".tool-sheet.open")?"open":"closed"') if $closed == "closed" { log-ok "Escape closes the tool sheet"; $p = $p + 1 } else { log-fail "Escape did not close the tool sheet"; $fl = $fl + 1 } # Focus returns to the FAB trigger after the sheet closes (a11y). let refocused = (wd-execute $session_id 'var a=document.activeElement; var m=(a&&a.querySelector)?a.querySelector(".material-icons"):null; return (a&&a.classList.contains("fab")&&m&&m.textContent=="bolt")?"y":"n"') if $refocused == "y" { log-ok "focus returns to the trigger on sheet close"; $p = $p + 1 } else { log-fail "focus not returned to the trigger on close"; $fl = $fl + 1 } # Re-open the sheet for the export check. wd-execute $session_id 'var t=document.querySelector(".fab"); if(t)t.click(); return 1' | ignore sleep 500ms } else { log-warn "tools sheet is docked (extra-large) — skipping modal open/Escape/focus checks" } let exp = (wd-execute $session_id 'var b=[...document.querySelectorAll(".tool-sheet .sheet-action")].find(x=>{var m=x.querySelector(".material-icons"); return m&&m.textContent=="download"}); if(b){b.click(); return "y"} return "n"') if $exp == "y" { sleep 4sec let alive = (wd-execute $session_id 'return document.querySelector("#main .card")?"y":"n"') if $alive == "y" { log-ok "folder export (in tools sheet) runs without trapping"; $p = $p + 1 } else { log-fail "folder export trapped the app"; $fl = $fl + 1 } } else { log-warn "no folder export action in tools sheet — skipping export check" }
# ── Rich text editor: mount, seed, toolbar and live formatting ─────────── # Exercised entirely in the browser DOM without ever clicking Save, so no # node's stored content is modified. wd-navigate $session_id $"(base-url)($ctx_path)?app=editor" if (wd-wait-for-element $session_id "#rich-editor" 15) { # The editing surface is a real contenteditable element. let ce = (wd-execute $session_id 'return document.getElementById("rich-editor")?.getAttribute("contenteditable")') if $ce == "true" { log-ok "rich editor mounts (contenteditable)"; $p = $p + 1 capture-shots $session_id "editor" } else { log-fail $"rich editor not contenteditable: ($ce)"; $fl = $fl + 1 } # Toolbar: block-style dropdown plus the formatting icon buttons. let tools = (wd-execute $session_id 'return JSON.stringify({sel:document.querySelector(".editor-select")?1:0, btns:document.querySelectorAll(".editor-tools .btn-icon").length})') let ok = (try { let j = ($tools | from json); ($j.sel == 1) and ($j.btns >= 10) } catch { false }) if $ok { log-ok $"rich editor toolbar renders ($tools)"; $p = $p + 1 } else { log-fail $"editor toolbar incomplete: ($tools)"; $fl = $fl + 1 } # Seeded from the node's Slate content (an empty doc still seeds a block). let seeded = (wd-execute $session_id 'var e=document.getElementById("rich-editor"); return e && e.children.length>0 ? "y":"n"') if $seeded == "y" { log-ok "rich editor seeds content"; $p = $p + 1 } else { log-fail "rich editor empty (not seeded)"; $fl = $fl + 1 } # Critical: a Dioxus re-render (triggered via keyup, which fires the # toolbar-state handler) must NOT wipe the browser-owned editor DOM. wd-execute $session_id 'var e=document.getElementById("rich-editor"); e.innerHTML="<p>keepme-xyz</p>"; e.dispatchEvent(new KeyboardEvent("keyup",{bubbles:true})); return 1' | ignore sleep 1sec let kept = (wd-execute $session_id 'return (document.getElementById("rich-editor")||{innerHTML:""}).innerHTML.indexOf("keepme-xyz")>=0?"y":"n"') if $kept == "y" { log-ok "editor survives re-render (contenteditable not clobbered)"; $p = $p + 1 } else { log-fail "editor content wiped on re-render"; $fl = $fl + 1 } # Live inline/block formatting via execCommand. Servo only partially # implements contenteditable execCommand (bold/formatBlock are no-ops), so # these run under Firefox and warn-skip on Servo. if (servo-skip "editor execCommand (bold / formatBlock)") { } else { # Live inline formatting: select all and toggle bold via execCommand. let bolded = (wd-execute $session_id 'var e=document.getElementById("rich-editor"); if(!e) return "no"; if(!e.innerText.trim()){e.innerHTML="<p>sample text</p>";} e.focus(); document.execCommand("selectAll",false,null); document.execCommand("bold",false,null); return (/<(b|strong)\b/i.test(e.innerHTML)||/font-weight/i.test(e.innerHTML))?"y":"n"') if $bolded == "y" { log-ok "editor bold command applies"; $p = $p + 1 } else { log-fail "editor bold command had no effect"; $fl = $fl + 1 } # Live block formatting: turn the selection into a heading. let blocked = (wd-execute $session_id 'var e=document.getElementById("rich-editor"); e.focus(); document.execCommand("selectAll",false,null); document.execCommand("formatBlock",false,"<h1>"); return e.querySelector("h1")?"y":"n"') if $blocked == "y" { log-ok "editor block-format applies"; $p = $p + 1 # Screenshot the editor with a heading so the fluid (container-query) # document type is visible: a big h1 on desktop, smaller on mobile. capture-shots $session_id "editor-h1" # The h1 must actually shrink on a narrow column (fluid type check). let h1sz = (wd-execute $session_id 'var e=document.getElementById("rich-editor"); var h=e&&e.querySelector("h1"); return h?Math.round(parseFloat(getComputedStyle(h).fontSize)):0') log-ok $"editor h1 computed font-size at desktop: ($h1sz)px" } else { log-fail "editor formatBlock had no effect"; $fl = $fl + 1 } } } else { log-warn "rich editor did not mount, skipping editor checks" }
# ── Author field: hidden for contexts, shown for content nodes ─────────── # Contexts (group/event) do not carry authors; documents/policies do. wd-navigate $session_id $"(base-url)($ctx_path)?app=editor" if (wd-wait-for-element $session_id "#rich-editor" 15) { let ctx_af = (wd-execute $session_id 'return document.querySelector(".author-field")?"y":"n"') if $ctx_af == "n" { log-ok "context editor has no author field"; $p = $p + 1 } else { log-fail "context editor unexpectedly shows an author field"; $fl = $fl + 1 } } # Open a child node's editor; a content node shows the author autocomplete. wd-navigate $session_id $"(base-url)($ctx_path)" sleep 1sec let clicked = (wd-execute $session_id 'var e=document.querySelector("#main .folder-tile, #main .list-link"); if(e){e.click(); return "y"} return "n"') if $clicked == "y" { sleep 2sec let child_path = (wd-execute $session_id 'return location.pathname') wd-navigate $session_id $"(base-url)($child_path)?app=editor" if (wd-wait-for-element $session_id "#rich-editor" 15) { let af = (wd-execute $session_id 'return document.querySelector(".author-field .author-input-wrap input")?"y":"n"') if $af == "y" { log-ok "content editor shows the author field"; $p = $p + 1 } else { log-warn "child node does not carry authors, skipping author-field presence check" } } } else { log-warn "no child node to open, skipping author-field presence check" }
# ── Teardown: delete the hermetic group subtree + its permissions (only when # we created one; the discovery fallback touches nothing to clean up). The # group and its nested event are each their own context, so delete perms for # both. Leaves the live backend exactly as it was found. ── if ($hermetic_gid | is-not-empty) { let eid = ($setup.event | default "") let teardown_js = ($gql + 'var GID="' + $hermetic_gid + '";var EID="' + $eid + '";function ch(pid){var r=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p}}){id}}",{p:pid});var o=[];try{o=r.data.nodes.map(function(n){return n.id})}catch(e){}return o;}var all=[];var stack=[GID];var guard=0;while(stack.length&&guard<800){guard++;var id=stack.pop();var kids=ch(id);for(var i=0;i<kids.length;i++){all.push(kids[i]);stack.push(kids[i]);}}for(var i=0;i<all.length;i++){gql("mutation($i:uuid!){deleteNode(id:$i){id}}",{i:all[i]});}var ctxs=[GID];if(EID)ctxs.push(EID);gql("mutation($c:[uuid!]!){deletePermissions(where:{contextId:{_in:$c}}){affected_rows}}",{c:ctxs});gql("mutation($p:uuid!){deleteMembers(where:{parentId:{_eq:$p}}){affected_rows}}",{p:GID});gql("mutation($i:uuid!){deleteNode(id:$i){id}}",{i:GID});var chk=gql("query($i:uuid!){node(id:$i){id}}",{i:GID});var gone=true;try{gone=!chk.data.node}catch(e){}return JSON.stringify({deleted:all.length,gone:gone});') let td = (try { wd-execute $session_id $teardown_js | from json } catch { {deleted: -1, gone: false} }) if ($td.gone == true) { log-ok $"cleaned up the hermetic group \(($td.deleted) descendant nodes\) + permissions"; $p = $p + 1 } else { log-fail $"cleanup incomplete, MANUAL CHECK: group ($hermetic_gid) deleted=($td.deleted) gone=($td.gone)"; $fl = $fl + 1 } }
{ passed: $p, failed: $fl }}
# Create a child node through the folder "Add content" dialog, exactly as a user# would: open the dialog, pick the given mime from the (permission-derived) type# menu, name it, and submit. This is the same control that offers motions# (vote/policy) and elections (vote/position) once the parent allows them. Returns# true if it drove the dialog to submit. Verification is left to the caller (query# the backend for the created node).def wd-folder-add [session_id: string, mime: string, name: string]: nothing -> bool { if not (wd-wait-y $session_id 'return [...document.querySelectorAll("#main button.add-action")].some(function(b){return b.getAttribute("aria-label")=="Add content"})?"y":"n"' 8000) { return false } wd-execute $session_id 'var b=[...document.querySelectorAll("#main button.add-action")].find(function(b){return b.getAttribute("aria-label")=="Add content"});if(b)b.click();return 1' | ignore sleep 500ms # The kinds are icon buttons (a <select> cannot show an icon), offered from # what the parent allows, so wait for the one we want before pressing it # (proves the kind is genuinely offered by the UI). Found by its icon: the # label is translated. let icon = ({"wiki/document": "article", "wiki/folder": "folder", "wiki/file": "upload_file", "vote/policy": "gavel", "vote/position": "how_to_reg"} | get -o $mime | default "") let kind_btn = ('[...document.querySelectorAll(".m3-dialog [role=group] button")].find(function(b){var i=b.querySelector(".material-icons");return i&&i.textContent.trim()=="' + $icon + '"})') if ($icon | is-empty) or not (wd-wait-y $session_id ('return ' + $kind_btn + '?"y":"n"') 8000) { return false } wd-execute $session_id ('var b=' + $kind_btn + ';if(b)b.click();return 1') | ignore sleep 300ms wd-execute $session_id ('var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="' + $name + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 300ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore sleep 1700ms true}
# Delete a context node's whole subtree (depth-first), then its permissions and# the node itself, via GraphQL. Returns { deleted, gone }. Used to tear down the# throwaway write-flow fixtures so a run leaves the live backend exactly as found.def wd-teardown-context [session_id: string, gql: string, node_id: string]: nothing -> record<deleted: int, gone: bool> { let js = ($gql + 'var GID="' + $node_id + '";function ch(pid){var r=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p}}){id}}",{p:pid});var o=[];try{o=r.data.nodes.map(function(n){return n.id})}catch(e){}return o;}var all=[];var stack=[GID];var guard=0;while(stack.length&&guard<800){guard++;var id=stack.pop();var kids=ch(id);for(var i=0;i<kids.length;i++){all.push(kids[i]);stack.push(kids[i]);}}for(var i=0;i<all.length;i++){gql("mutation($i:uuid!){deleteNode(id:$i){id}}",{i:all[i]});}gql("mutation($c:uuid!){deletePermissions(where:{contextId:{_eq:$c}}){affected_rows}}",{c:GID});gql("mutation($i:uuid!){deleteNode(id:$i){id}}",{i:GID});var chk=gql("query($i:uuid!){node(id:$i){id}}",{i:GID});var gone=true;try{gone=!chk.data.node}catch(e){}return JSON.stringify({deleted:all.length,gone:gone});') (try { wd-execute $session_id $js | from json } catch { {deleted: -1, gone: false} })}
# Hermetic write-flow, driven end to end through the real UI. Create a throwaway# GROUP and an EVENT from the home "New group"/"New event" controls, then add a# folder, a motion (vote/policy) and an election (vote/position) through the folder# "Add content" dialog, and run the full civic loop on them (start polls, cast# votes, post a comment, add candidates, create a speaker list) — each verified# against the backend. Nothing is seeded through GraphQL: every node is made by# clicking the app's own controls, so the test also guards that those creation# paths exist. Teardown deletes the whole group subtree + the event + their# permissions, so a run depends on NOTHING but the root and leaves the live# backend exactly as it found it. Firefox-only; warn-skips on Servo.def test-vote-flow [session_id: string, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed log-info "" log-info "── Poll create + vote + comment (hermetic write-flow) ───" if (servo-skip "poll/vote/comment write-flow") { return { passed: $p, failed: $fl } } let GQL = (gql-url) # gql() prelude: read the session token from localStorage, sync-XHR to Hasura. let gql = (gql-prelude $GQL) let t = (date now | format date '%Y%m%d%H%M%S') let gname = $"E2E hermetic ($t)" let ename = $"E2E event ($t)" let fname = $"E2E folder ($t)" let plname = $"E2E policy ($t)" let poname = $"E2E election ($t)"
# ── Setup, driven entirely through the app's own controls ───────────────── # 1) Create the group from the home "New group" control (the create flow # navigates into it); read its id/key back from the backend to confirm it # was made as its own context. wd-navigate $session_id $"(base-url)/" sleep 1sec if not (wd-wait-y $session_id 'return [...document.querySelectorAll("button.add-action")].some(function(b){return b.getAttribute("aria-label")=="New group"})?"y":"n"' 8000) { log-fail "no add-group control on home (owner)"; $fl = $fl + 1 return { passed: $p, failed: $fl } } wd-execute $session_id 'var b=[...document.querySelectorAll("button.add-action")].find(function(b){return b.getAttribute("aria-label")=="New group"});if(b)b.click();return 1' | ignore sleep 500ms wd-execute $session_id ('var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="' + $gname + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 400ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore for _ in 1..20 { let path = (wd-execute $session_id 'return location.pathname'); if ($path != "/") and ($path != null) { break }; sleep 300ms } let grp = (try { wd-execute $session_id ($gql + 'var r=gql("query($n:String!){nodes(where:{name:{_eq:$n},mimeId:{_eq:\"wiki/group\"}}){id key contextId}}",{n:"' + $gname + '"});var o={id:null,key:null,self:false};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key;o.self=(o.id==r.data.nodes[0].contextId)}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null, self: false} }) if ($grp.id | is-empty) { log-fail "group not created via the home UI"; $fl = $fl + 1 return { passed: $p, failed: $fl } } if ($grp.self == true) { log-ok "group created via home UI (its own context)"; $p = $p + 1 } else { log-ok "group created via home UI"; $p = $p + 1 }
# 2) Create an event from the home "New event" control (soft: logged but not # fatal), verified as its own context. wd-navigate $session_id $"(base-url)/" sleep 800ms mut evt_id = "" if (wd-wait-y $session_id 'return [...document.querySelectorAll("button.add-action")].some(function(b){return b.getAttribute("aria-label")=="New event"})?"y":"n"' 8000) { wd-execute $session_id 'var b=[...document.querySelectorAll("button.add-action")].find(function(b){return b.getAttribute("aria-label")=="New event"});if(b)b.click();return 1' | ignore sleep 500ms wd-execute $session_id ('var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="' + $ename + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 400ms # An event belongs to a group, and the dialog asks which: the one just made. wd-execute $session_id ('var s=document.querySelector(".m3-dialog select");if(s){var o=[...s.options].find(function(o){return o.textContent.trim()=="' + $gname + '"});if(o){s.value=o.value;s.dispatchEvent(new Event("change",{bubbles:true}))}}return 1') | ignore sleep 400ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore for _ in 1..20 { let path = (wd-execute $session_id 'return location.pathname'); if ($path != "/") and ($path != null) { break }; sleep 300ms } let evt = (try { wd-execute $session_id ($gql + 'var r=gql("query($n:String!){nodes(where:{name:{_eq:$n},mimeId:{_eq:\"wiki/event\"}}){id contextId}}",{n:"' + $ename + '"});var o={id:null,self:false};try{o.id=r.data.nodes[0].id;o.self=(o.id==r.data.nodes[0].contextId)}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, self: false} }) if (($evt.id | is-not-empty) and ($evt.self == true)) { log-ok "event created via home UI (its own context)"; $p = $p + 1 $evt_id = $evt.id } else { log-fail "event not created via the home UI"; $fl = $fl + 1 } } else { log-fail "no add-event control on home (owner)"; $fl = $fl + 1 }
# 3) Add a folder inside the group through the "Add content" dialog. wd-navigate $session_id $"(base-url)/($grp.key)" sleep 800ms wd-folder-add $session_id "wiki/folder" $fname | ignore let fld = (try { wd-execute $session_id ($gql + 'var P="' + $grp.id + '";var r=gql("query($p:uuid!,$n:String!){nodes(where:{parentId:{_eq:$p},name:{_eq:$n},mimeId:{_eq:\"wiki/folder\"}}){id key}}",{p:P,n:"' + $fname + '"});var o={id:null,key:null};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null} }) if ($fld.id | is-empty) { log-fail "folder not created via the Add-content UI"; $fl = $fl + 1 wd-teardown-context $session_id $gql $grp.id | ignore if ($evt_id | is-not-empty) { wd-teardown-context $session_id $gql $evt_id | ignore } return { passed: $p, failed: $fl } } log-ok "folder created via the Add-content UI"; $p = $p + 1
# 4) Add a motion (vote/policy) and an election (vote/position) inside the # folder — the type-menu entries the old wiki offered and this port had # dropped, now restored. The election feeds the candidate flow below. wd-navigate $session_id $"(base-url)/($grp.key)/($fld.key)" sleep 800ms wd-folder-add $session_id "vote/policy" $plname | ignore let pol = (try { wd-execute $session_id ($gql + 'var P="' + $fld.id + '";var r=gql("query($p:uuid!,$n:String!){nodes(where:{parentId:{_eq:$p},name:{_eq:$n},mimeId:{_eq:\"vote/policy\"}}){id key}}",{p:P,n:"' + $plname + '"});var o={id:null,key:null};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null} }) if ($pol.id | is-empty) { log-fail "motion (vote/policy) not creatable via the Add-content UI"; $fl = $fl + 1 wd-teardown-context $session_id $gql $grp.id | ignore if ($evt_id | is-not-empty) { wd-teardown-context $session_id $gql $evt_id | ignore } return { passed: $p, failed: $fl } } log-ok "motion created via the Add-content UI"; $p = $p + 1 # Making a page goes to the page: back to the folder for the next one. wd-navigate $session_id $"(base-url)/($grp.key)/($fld.key)" sleep 800ms wd-folder-add $session_id "vote/position" $poname | ignore let pos = (try { wd-execute $session_id ($gql + 'var P="' + $fld.id + '";var r=gql("query($p:uuid!,$n:String!){nodes(where:{parentId:{_eq:$p},name:{_eq:$n},mimeId:{_eq:\"vote/position\"}}){id key}}",{p:P,n:"' + $poname + '"});var o={id:null,key:null};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null} }) if ($pos.id | is-not-empty) { log-ok "election created via the Add-content UI"; $p = $p + 1 } else { log-fail "election (vote/position) not creatable via the Add-content UI"; $fl = $fl + 1 }
let setup = { group: $grp.id, groupKey: $grp.key, folder: $fld.id, folderKey: $fld.key, policy: $pol.id, policyKey: $pol.key, position: ($pos.id | default ""), positionKey: ($pos.key | default ""), event: $evt_id } let pid = $setup.policy let ppath = $"/($setup.groupKey)/($setup.folderKey)/($setup.policyKey)"
# ── Add a poll (UI: the StartPollButton on the policy) ── wd-navigate $session_id $"(base-url)($ppath)" if (wd-wait-y $session_id 'return [...document.querySelectorAll(".sheet-action")].some(function(b){var m=b.querySelector(".material-icons");return m&&m.textContent.trim()=="ballot"})?"y":"n"' 8000) { log-ok "poll-start control shown (owner)"; $p = $p + 1 wd-execute $session_id 'var b=[...document.querySelectorAll(".sheet-action")].find(function(b){var m=b.querySelector(".material-icons");return m&&m.textContent.trim()=="ballot"});if(b)b.click();return 1' | ignore sleep 700ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore if (wd-wait-y $session_id 'return document.querySelector("#main .ballot-option, #main .btn-cast")?"y":"n"' 9000) { log-ok "poll created and ballot rendered"; $p = $p + 1 } else { log-fail "poll did not open a ballot"; $fl = $fl + 1 } } else { log-fail "poll-start control missing on the policy"; $fl = $fl + 1 }
# ── Cast a vote (UI), verified against the backend ── wd-execute $session_id 'var os=[...document.querySelectorAll("#main .ballot-option")];var t=os.find(function(o){return /\bFor\b/.test(o.textContent)})||os[0];if(t)t.click();return 1' | ignore sleep 500ms wd-execute $session_id 'var b=document.querySelector("#main .btn-cast");if(b)b.click();return 1' | ignore sleep 1800ms let vres = (try { wd-execute $session_id ($gql + 'var PID="' + $pid + '";var pr=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/poll\"}}){id}}",{p:PID});var poll=null;try{poll=pr.data.nodes[0].id}catch(e){}var vc=0;if(poll){var vr=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/vote\"}}){id}}",{p:poll});try{vc=vr.data.nodes.length}catch(e){}}return JSON.stringify({poll:poll,votes:vc});') | from json } catch { {poll: null, votes: 0} }) if (($vres.votes | default 0) >= 1) { log-ok $"vote cast and recorded on the poll \(votes=($vres.votes)\)"; $p = $p + 1 } else { log-fail "vote not recorded on the poll"; $fl = $fl + 1 }
# ── Post a comment (UI, on the policy's CommentSection), verified via backend ── wd-navigate $session_id $"(base-url)($ppath)" if (wd-wait-y $session_id 'return document.querySelector("#main .comment-composer .comment-input")?"y":"n"' 8000) { wd-execute $session_id 'var ta=document.querySelector("#main .comment-composer .comment-input");if(ta){ta.value="e2e comment "+Date.now();ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1' | ignore sleep 500ms wd-execute $session_id 'var b=document.querySelector("#main .comment-composer button[aria-label=Send]");if(b)b.click();return 1' | ignore sleep 1800ms let cres = (try { wd-execute $session_id ($gql + 'var PID="' + $pid + '";var cr=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/comment\"}}){id}}",{p:PID});var cc=0;try{cc=cr.data.nodes.length}catch(e){}return JSON.stringify({comments:cc});') | from json } catch { {comments: 0} }) if (($cres.comments | default 0) >= 1) { log-ok $"comment posted and recorded on the policy \(comments=($cres.comments)\)"; $p = $p + 1 } else { log-fail "comment not recorded on the policy"; $fl = $fl + 1 } } else { log-fail "comment composer missing on the policy"; $fl = $fl + 1 }
# ── Add a speaker list (UI: owner action in the speak app), verified via backend ── wd-navigate $session_id $"(base-url)/($setup.groupKey)?app=speak" if (wd-wait-y $session_id 'return [...document.querySelectorAll("#main button")].some(function(b){return b.textContent.indexOf("New speaker list")>=0})?"y":"n"' 8000) { log-ok "add-speaker-list control shown (owner)"; $p = $p + 1 wd-execute $session_id 'var b=[...document.querySelectorAll("#main button")].find(function(b){return b.textContent.indexOf("New speaker list")>=0});if(b)b.click();return 1' | ignore sleep 500ms wd-execute $session_id 'var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="Talerliste 1";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1' | ignore sleep 400ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore sleep 1800ms let lres = (try { wd-execute $session_id ($gql + 'var G="' + $setup.group + '";var r=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"speak/list\"}}){id}}",{p:G});var n=0;try{n=r.data.nodes.length}catch(e){}return JSON.stringify({lists:n});') | from json } catch { {lists: 0} }) if (($lres.lists | default 0) >= 1) { log-ok $"speaker list created \(lists=($lres.lists)\)"; $p = $p + 1 } else { log-fail "speaker list not created"; $fl = $fl + 1 } } else { log-fail "add-speaker-list control missing in the speak app"; $fl = $fl + 1 }
# ── Person election: add 10 candidates (UI) -> poll allowing 5 selections ── # -> cast a five-candidate ballot -> verify. Exercises the inline # AddCandidateButton at volume, the multi-select ballot (maxVote>1 renders a # checkbox per candidate, capped at 5 with over-vote blocked), and that the # recorded vote carries exactly the five chosen options. if ($setup.position | is-not-empty) { let pospath = $"/($setup.groupKey)/($setup.folderKey)/($setup.positionKey)" wd-navigate $session_id $"(base-url)($pospath)" # Add ten candidates through the inline add-candidate dialog (name only). for i in 1..10 { let cname = $"Candidate ($i)" # Adding one lands in its editor, for its text to be written: back to # the election for the next. if $i > 1 { wd-navigate $session_id $"(base-url)($pospath)" } if (wd-wait-y $session_id 'return [...document.querySelectorAll("#main button[aria-label]")].some(function(b){return b.getAttribute("aria-label")=="Add candidate"})?"y":"n"' 8000) { wd-execute $session_id 'var b=[...document.querySelectorAll("#main button[aria-label]")].find(function(x){return x.getAttribute("aria-label")=="Add candidate"});if(b)b.click();return 1' | ignore sleep 450ms wd-execute $session_id ('var ta=document.querySelector(".m3-dialog input[type=text]");if(ta){ta.value="' + $cname + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 250ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore sleep 1300ms } } let cand = (try { wd-execute $session_id ($gql + 'var P="' + $setup.position + '";var r=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/candidate\"}}){id}}",{p:P});var n=0;try{n=r.data.nodes.length}catch(e){}return JSON.stringify({candidates:n});') | from json } catch { {candidates: 0} }) if (($cand.candidates | default 0) >= 10) { log-ok $"ten candidates added to the election \(candidates=($cand.candidates))"; $p = $p + 1 } else { log-fail $"election: expected 10 candidates, got ($cand.candidates)"; $fl = $fl + 1 } # Start a poll and allow voting for up to 5 of the candidates: the dialog # shows two vote-range sliders (min, max) once there are >2 options; drag # the max (second) slider to 5. wd-navigate $session_id $"(base-url)($pospath)" if (wd-wait-y $session_id 'return [...document.querySelectorAll(".sheet-action")].some(function(b){var m=b.querySelector(".material-icons");return m&&m.textContent.trim()=="ballot"})?"y":"n"' 8000) { wd-execute $session_id 'var b=[...document.querySelectorAll(".sheet-action")].find(function(b){var m=b.querySelector(".material-icons");return m&&m.textContent.trim()=="ballot"});if(b)b.click();return 1' | ignore sleep 700ms let maxset = (wd-execute $session_id 'var rs=[...document.querySelectorAll(".m3-dialog input[type=range]")];if(rs.length<2)return rs.length.toString();var mx=rs[1];mx.value="5";mx.dispatchEvent(new Event("input",{bubbles:true}));return rs.length.toString()') if ($maxset == "2") { log-ok "poll dialog exposes min/max vote sliders for a multi-candidate election"; $p = $p + 1 } else { log-fail $"expected two vote-range sliders, saw ($maxset)"; $fl = $fl + 1 } sleep 350ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore if (wd-wait-y $session_id 'return document.querySelector("#main .ballot-option")?"y":"n"' 9000) { # maxVote>1 makes the ballot multi-select: a checkbox per candidate. let ischk = (wd-execute $session_id 'return document.querySelector("#main .ballot-option button[role=checkbox]")?"y":"n"') if ($ischk == "y") { log-ok "election poll created, multi-select candidate ballot rendered"; $p = $p + 1 } else { log-fail "ballot did not render candidate checkboxes"; $fl = $fl + 1 } # Select five candidates (Blank is always last, so the first five # checkboxes are candidates); confirm five register as checked. wd-execute $session_id 'var bx=[...document.querySelectorAll("#main .ballot-option button[role=checkbox]")];for(var i=0;i<5;i++){if(bx[i])bx[i].click();}return 1' | ignore sleep 700ms let nchk = (wd-execute $session_id 'return [...document.querySelectorAll("#main .ballot-option button[role=checkbox]")].filter(function(b){return b.getAttribute("data-state")=="checked"}).length.toString()') if ($nchk == "5") { log-ok "selected five candidates on the ballot"; $p = $p + 1 } else { log-fail $"expected 5 selections, got ($nchk)"; $fl = $fl + 1 } # A sixth selection must be refused (over-vote guard holds at max=5). wd-execute $session_id 'var bx=[...document.querySelectorAll("#main .ballot-option button[role=checkbox]")];for(var i=0;i<bx.length;i++){if(bx[i].getAttribute("data-state")!="checked"){bx[i].click();break;}}return 1' | ignore sleep 500ms let sixth = (wd-execute $session_id 'return [...document.querySelectorAll("#main .ballot-option button[role=checkbox]")].filter(function(b){return b.getAttribute("data-state")=="checked"}).length.toString()') if ($sixth == "5") { log-ok "over-vote blocked: a sixth selection is refused (cap held at 5)"; $p = $p + 1 } else { log-fail $"over-vote not blocked \(checked=($sixth))"; $fl = $fl + 1 } # Cast the five-candidate ballot; verify the recorded vote lists 5 choices. wd-execute $session_id 'var b=document.querySelector("#main .btn-cast");if(b)b.click();return 1' | ignore sleep 2200ms let evres = (try { wd-execute $session_id ($gql + 'var P="' + $setup.position + '";var pr=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/poll\"}}){id}}",{p:P});var poll=null;try{poll=pr.data.nodes[0].id}catch(e){}var votes=0,choices=-1;if(poll){var vr=gql("query($p:uuid!){nodes(where:{parentId:{_eq:$p},mimeId:{_eq:\"vote/vote\"}}){id data}}",{p:poll});try{votes=vr.data.nodes.length;var d=vr.data.nodes[0].data;if(Array.isArray(d))choices=d.length;}catch(e){}}return JSON.stringify({poll:poll,votes:votes,choices:choices});') | from json } catch { {poll: null, votes: 0, choices: -1} }) if ((($evres.votes | default 0) >= 1) and (($evres.choices | default (-1)) == 5)) { log-ok $"cast a five-candidate ballot; the vote records 5 choices \(votes=($evres.votes))"; $p = $p + 1 } else { log-fail $"five-candidate vote not recorded correctly \(votes=($evres.votes) choices=($evres.choices))"; $fl = $fl + 1 } } else { log-fail "election poll did not open a ballot"; $fl = $fl + 1 } } else { log-fail "poll-start control missing on the position"; $fl = $fl + 1 } } else { log-warn "no hermetic position — skipping election flow" }
# ── Teardown (always runs): delete the whole group subtree + the event and # their permissions, then verify the group is gone. Both are throwaways of # our own, so nothing needs restoring (the `active` relation cascades). ── let td = (wd-teardown-context $session_id $gql $setup.group) if ($td.gone == true) { log-ok $"cleaned up the hermetic group \(($td.deleted) descendant nodes\) + permissions"; $p = $p + 1 } else { log-fail $"cleanup incomplete, MANUAL CHECK: group ($setup.group) deleted=($td.deleted) gone=($td.gone)"; $fl = $fl + 1 } if ($setup.event | is-not-empty) { let etd = (wd-teardown-context $session_id $gql $setup.event) if ($etd.gone == true) { log-ok "cleaned up the hermetic event + permissions"; $p = $p + 1 } else { log-fail $"cleanup incomplete, MANUAL CHECK: event ($setup.event) gone=($etd.gone)"; $fl = $fl + 1 } }
{ passed: $p, failed: $fl }}
# Full editor feature pass, driven end to end through the real UI on a hermetic# throwaway GROUP (a context, so saving needs no authors): type text, apply every# toolbar control (inline marks, code, link, heading, lists, alignment,# undo/redo), paste sanitised HTML and plain text, rename the title, SAVE, verify# the stored Slate content in the backend, re-open the editor to prove the saved# content seeds back into the surface (slate -> html round trip), save again# without losing formatting, and let the debounced autosave persist a draft# without ever clicking Save. Firefox-only; warn-skips on Servo (execCommand +# synthetic ClipboardEvent). Teardown deletes the group, leaving the live backend# exactly as found.def test-editor-flow [session_id: string, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed log-info "" log-info "── Editor: format / paste / save / re-edit / autosave ───" if (servo-skip "editor write-flow (execCommand + paste + save)") { return { passed: $p, failed: $fl } } let GQL = (gql-url) # gql() prelude: read the session token from localStorage, sync-XHR to Hasura. let gql = (gql-prelude $GQL) # Editor-DOM prelude for every editor-side execute: the surface plus helpers to # put the caret at the end, select a word by its text, and click a toolbar # icon button the way a user would. let ed = ('var E=document.getElementById("rich-editor");function endCaret(){E.focus();var r=document.createRange();r.selectNodeContents(E);r.collapse(false);var s=getSelection();s.removeAllRanges();s.addRange(r);}function selWord(w){var tw=document.createTreeWalker(E,NodeFilter.SHOW_TEXT);var n;while((n=tw.nextNode())){var i=n.data.indexOf(w);if(i>=0){var r=document.createRange();r.setStart(n,i);r.setEnd(n,i+w.length);var s=getSelection();s.removeAllRanges();s.addRange(r);return true}}return false}function tbtn(ic){var b=[...document.querySelectorAll(".editor-tools .btn-icon")].find(function(x){var m=x.querySelector(".material-icons");return m&&m.textContent.trim()==ic});if(b){b.click();return true}return false}') let t = (date now | format date '%Y%m%d%H%M%S') let gname = $"E2E editor ($t)"
# ── Setup: a throwaway group via the home "New group" control ── wd-navigate $session_id $"(base-url)/" sleep 1sec if not (wd-wait-y $session_id 'return [...document.querySelectorAll("button.add-action")].some(function(b){return b.getAttribute("aria-label")=="New group"})?"y":"n"' 8000) { log-fail "no add-group control on home (owner)"; $fl = $fl + 1 return { passed: $p, failed: $fl } } wd-execute $session_id 'var b=[...document.querySelectorAll("button.add-action")].find(function(b){return b.getAttribute("aria-label")=="New group"});if(b)b.click();return 1' | ignore sleep 500ms wd-execute $session_id ('var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="' + $gname + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 400ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore for _ in 1..20 { let path = (wd-execute $session_id 'return location.pathname'); if ($path != "/") and ($path != null) { break }; sleep 300ms } let grp = (try { wd-execute $session_id ($gql + 'var r=gql("query($n:String!){nodes(where:{name:{_eq:$n},mimeId:{_eq:\"wiki/group\"}}){id key}}",{n:"' + $gname + '"});var o={id:null,key:null};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null} }) if ($grp.id | is-empty) { log-fail "editor-flow group not created via the home UI"; $fl = $fl + 1 return { passed: $p, failed: $fl } } log-ok "editor-flow group created via the home UI"; $p = $p + 1 let ed_path = $"/($grp.key)?app=editor"
# ── Open the editor and type a multi-line document ── wd-navigate $session_id $"(base-url)($ed_path)" if not (wd-wait-for-element $session_id "#rich-editor" 30) { log-fail "editor did not mount for the editor-flow group"; $fl = $fl + 1 wd-teardown-context $session_id $gql $grp.id | ignore return { passed: $p, failed: $fl } } sleep 500ms let typed = (wd-execute $session_id ($ed + 'endCaret();function line(x){document.execCommand("insertText",false,x)}line("Alpha BoldWord ItalicWord UnderWord StrikeWord CodeWord LinkWord");document.execCommand("insertParagraph");line("HeadLine");document.execCommand("insertParagraph");line("BulletItem");document.execCommand("insertParagraph");line("NumItem");document.execCommand("insertParagraph");line("CenterLine");return E.innerText.indexOf("CenterLine")>=0?"y":"n"')) if $typed == "y" { log-ok "editor accepts typed multi-line input (insertText)"; $p = $p + 1 } else { log-fail "typing into the editor produced no content"; $fl = $fl + 1 wd-teardown-context $session_id $gql $grp.id | ignore return { passed: $p, failed: $fl } }
# ── Bold via the toolbar button, then undo / redo via their buttons ── let bur = (try { wd-execute $session_id ($ed + 'selWord("BoldWord");tbtn("format_bold");var b1=/<(b|strong)[^>]*>BoldWord/.test(E.innerHTML);tbtn("undo");var b2=!/<(b|strong)[^>]*>BoldWord/.test(E.innerHTML);tbtn("redo");var b3=/<(b|strong)[^>]*>BoldWord/.test(E.innerHTML);return JSON.stringify({apply:b1,undo:b2,redo:b3})') | from json } catch { {apply: false, undo: false, redo: false} }) if $bur.apply { log-ok "toolbar bold applies to the selection"; $p = $p + 1 } else { log-fail "toolbar bold had no effect"; $fl = $fl + 1 } if ($bur.undo and $bur.redo) { log-ok "toolbar undo / redo revert and restore the bold"; $p = $p + 1 } else { log-fail $"toolbar undo/redo broken \(undo=($bur.undo) redo=($bur.redo))"; $fl = $fl + 1 }
# ── The remaining inline marks: italic, underline, strikethrough, code ── let marks = (try { wd-execute $session_id ($ed + 'selWord("ItalicWord");tbtn("format_italic");selWord("UnderWord");tbtn("format_underlined");selWord("StrikeWord");tbtn("strikethrough_s");selWord("CodeWord");tbtn("code");var h=E.innerHTML;return JSON.stringify({i:/<(i|em)[^>]*>ItalicWord/.test(h),u:/<u[^>]*>UnderWord/.test(h),s:/<(s|strike|del)[^>]*>StrikeWord/.test(h),c:/<code[^>]*>CodeWord/.test(h)})') | from json } catch { {i: false, u: false, s: false, c: false} }) if $marks.i { log-ok "toolbar italic applies"; $p = $p + 1 } else { log-fail "toolbar italic had no effect"; $fl = $fl + 1 } if $marks.u { log-ok "toolbar underline applies"; $p = $p + 1 } else { log-fail "toolbar underline had no effect"; $fl = $fl + 1 } if $marks.s { log-ok "toolbar strikethrough applies"; $p = $p + 1 } else { log-fail "toolbar strikethrough had no effect"; $fl = $fl + 1 } if $marks.c { log-ok "toolbar code wraps the selection"; $p = $p + 1 } else { log-fail "toolbar code had no effect"; $fl = $fl + 1 }
# ── Link: toolbar button opens the popover; enter a URL and add ── wd-execute $session_id ($ed + 'selWord("LinkWord");tbtn("link");return 1') | ignore sleep 400ms let link_set = (wd-execute $session_id 'var inp=document.querySelector(".link-popover .link-input");if(!inp)return "noinput";inp.value="https://example.com/e2e";inp.dispatchEvent(new Event("input",{bubbles:true}));return "y"') sleep 200ms wd-execute $session_id 'var b=document.querySelector(".link-popover .btn-primary");if(b)b.click();return 1' | ignore sleep 300ms let linked = (wd-execute $session_id ($ed + 'var a=[...E.querySelectorAll("a")].find(function(x){return x.textContent.indexOf("LinkWord")>=0});return a&&a.getAttribute("href")=="https://example.com/e2e"?"y":"n"')) if ($link_set == "y") and ($linked == "y") { log-ok "toolbar link popover links the selection"; $p = $p + 1 } else { log-fail $"link popover did not link \(popover=($link_set) linked=($linked))"; $fl = $fl + 1 }
# ── Block styles: heading via the style select, lists, centre alignment ── wd-execute $session_id ($ed + 'selWord("HeadLine");var sel=document.querySelector(".editor-select");sel.dispatchEvent(new MouseEvent("mousedown",{bubbles:true}));sel.value="heading-two";sel.dispatchEvent(new Event("change",{bubbles:true}));return 1') | ignore sleep 300ms wd-execute $session_id ($ed + 'selWord("BulletItem");tbtn("format_list_bulleted");selWord("NumItem");tbtn("format_list_numbered");selWord("CenterLine");tbtn("format_align_center");return 1') | ignore sleep 300ms let blocks = (try { wd-execute $session_id ($ed + 'var h=E.innerHTML;return JSON.stringify({h2:[...E.querySelectorAll("h2")].some(function(x){return x.textContent.indexOf("HeadLine")>=0}),ul:[...E.querySelectorAll("ul li")].some(function(x){return x.textContent.indexOf("BulletItem")>=0}),ol:[...E.querySelectorAll("ol li")].some(function(x){return x.textContent.indexOf("NumItem")>=0}),ce:/text-align:\s*center|align="center"/.test(h)})') | from json } catch { {h2: false, ul: false, ol: false, ce: false} }) if $blocks.h2 { log-ok "style select turns the line into a heading"; $p = $p + 1 } else { log-fail "style select (heading-two) had no effect"; $fl = $fl + 1 } if $blocks.ul { log-ok "toolbar bulleted list applies"; $p = $p + 1 } else { log-fail "toolbar bulleted list had no effect"; $fl = $fl + 1 } if $blocks.ol { log-ok "toolbar numbered list applies"; $p = $p + 1 } else { log-fail "toolbar numbered list had no effect"; $fl = $fl + 1 } if $blocks.ce { log-ok "toolbar centre alignment applies"; $p = $p + 1 } else { log-fail "toolbar centre alignment had no effect"; $fl = $fl + 1 }
# ── Paste: HTML is sanitised to the semantic subset (scripts/styles dropped # whole, wrappers unwrapped, only href survives on links) ── mut html_pasted = false # Firefox ignores the `clipboardData` ClipboardEventInit member, so shadow the # getter on the instance instead (the event stays a real ClipboardEvent, which # the wasm handler's downcast requires). let paste = (wd-execute $session_id ($ed + 'endCaret();var dt;try{dt=new DataTransfer()}catch(e){return "nodt"}dt.setData("text/html","<div><style>badcss{}</style><script>evil()</scr"+"ipt><span style=\"font-weight:700\">PastedSpan</span> <b onclick=\"h()\">PastedBold</b> <a href=\"https://example.com/paste\" onclick=\"h()\">PastedLink</a></div><h3>PastedHead</h3>");dt.setData("text/plain","PLAINFALLBACK");if(dt.getData("text/html")=="")return "nodt";var ev=new ClipboardEvent("paste",{bubbles:true,cancelable:true});Object.defineProperty(ev,"clipboardData",{value:dt});E.dispatchEvent(ev);var h=E.innerHTML;return JSON.stringify({span:h.indexOf("PastedSpan")>=0&&!/<span[^>]*>PastedSpan/.test(h),b:/<b[^>]*>PastedBold/.test(h)&&h.indexOf("onclick")<0,a:/<a [^>]*href="https:\/\/example.com\/paste"/.test(h),h3:/<h3[^>]*>PastedHead/.test(h),clean:h.indexOf("evil(")<0&&h.indexOf("badcss")<0&&h.indexOf("PLAINFALLBACK")<0})')) if $paste == "nodt" { log-warn "synthetic ClipboardEvent carries no data in this engine — skipping the HTML-paste checks" } else { let pj = (try { $paste | from json } catch { null }) if $pj == null { log-fail $"HTML paste probe returned non-JSON: ($paste)"; $fl = $fl + 1 } else { $html_pasted = true if ($pj.span and $pj.b and $pj.a and $pj.h3) { log-ok "pasted HTML keeps the semantic subset (b / a href / h3, span unwrapped)"; $p = $p + 1 } else { log-fail $"pasted HTML mangled: ($paste)"; $fl = $fl + 1 } if $pj.clean { log-ok "pasted HTML is sanitised (script / style / attributes dropped)"; $p = $p + 1 } else { log-fail $"pasted HTML kept dangerous content: ($paste)"; $fl = $fl + 1 } } # Plain-text-only paste falls back to escaped text with <br> line breaks. let ptext = (wd-execute $session_id ($ed + 'endCaret();var dt=new DataTransfer();dt.setData("text/plain","PlainLine1\nPlainLine2");var ev=new ClipboardEvent("paste",{bubbles:true,cancelable:true});Object.defineProperty(ev,"clipboardData",{value:dt});E.dispatchEvent(ev);var h=E.innerHTML;return (h.indexOf("PlainLine1")>=0&&h.indexOf("PlainLine2")>=0)?"y":"n"')) if $ptext == "y" { log-ok "plain-text paste inserts with line breaks"; $p = $p + 1 } else { log-fail "plain-text paste did not insert"; $fl = $fl + 1 } }
# ── Toolbar state reflects the caret (aria-pressed on the bold button) ── wd-execute $session_id ($ed + 'selWord("BoldWord");E.dispatchEvent(new KeyboardEvent("keyup",{bubbles:true}));return 1') | ignore sleep 400ms let tstate = (wd-execute $session_id 'var b=[...document.querySelectorAll(".editor-tools .btn-icon")].find(function(x){var m=x.querySelector(".material-icons");return m&&m.textContent.trim()=="format_bold"});return b&&b.getAttribute("aria-pressed")=="true"?"y":"n"') if $tstate == "y" { log-ok "toolbar state tracks the caret (bold shows active)"; $p = $p + 1 } else { log-fail "toolbar state did not reflect a bold selection"; $fl = $fl + 1 }
# ── Rename the title, then SAVE via the save button ── let gname2 = $"E2E editor renamed ($t)" wd-execute $session_id ('var ti=document.querySelector("#main .card-content .text-field input");if(ti){ti.value="' + $gname2 + '";ti.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 300ms let clicked_save = (wd-execute $session_id 'var b=[...document.querySelectorAll(".editor-toolbar .btn")].find(function(x){var m=x.querySelector(".material-icons");return m&&m.textContent.trim()=="save"});if(b){b.click();return "y"}return "n"') if (wd-wait-y $session_id 'return location.search.indexOf("app=editor")<0?"y":"n"' 10000) { log-ok "save navigates back to the node view"; $p = $p + 1 } else { log-fail $"save did not navigate away \(clicked=($clicked_save))"; $fl = $fl + 1 }
# ── Verify the stored Slate content in the backend ── let saved = (try { wd-execute $session_id ($gql + 'var GID="' + $grp.id + '";var r=gql("query($i:uuid!){node(id:$i){name data}}",{i:GID});var d=null;try{d=r.data.node}catch(e){}if(!d)return "nonode";var c=JSON.stringify(d.data&&d.data.content?d.data.content:{});return JSON.stringify({name:d.name,h2:c.indexOf("heading-two")>=0,ul:c.indexOf("bulleted-list")>=0,ol:c.indexOf("numbered-list")>=0,al:c.indexOf("\"align\":\"center\"")>=0,b:c.indexOf("\"bold\":true")>=0,i:c.indexOf("\"italic\":true")>=0,u:c.indexOf("\"underline\":true")>=0,st:c.indexOf("\"strikethrough\":true")>=0,co:c.indexOf("\"code\":true")>=0,li:c.indexOf("example.com/e2e")>=0,ps:c.indexOf("PastedSpan")>=0,ph:c.indexOf("PastedHead")>=0,pl:c.indexOf("example.com/paste")>=0,evil:c.indexOf("evil(")>=0})') | from json } catch { null }) if $saved == null { log-fail "could not read the saved node back from the backend"; $fl = $fl + 1 } else { if ($saved.name == $gname2) { log-ok "save persists the renamed title"; $p = $p + 1 } else { log-fail $"saved title wrong: ($saved.name)"; $fl = $fl + 1 } if ($saved.b and $saved.i and $saved.u and $saved.st and $saved.co and $saved.li) { log-ok "saved Slate content carries all inline marks + the link"; $p = $p + 1 } else { log-fail $"saved marks incomplete: ($saved)"; $fl = $fl + 1 } if ($saved.h2 and $saved.ul and $saved.ol and $saved.al) { log-ok "saved Slate content carries heading / lists / alignment"; $p = $p + 1 } else { log-fail $"saved blocks incomplete: ($saved)"; $fl = $fl + 1 } if $html_pasted { if ($saved.ps and $saved.ph and $saved.pl and (not $saved.evil)) { log-ok "saved content keeps the sanitised paste (and nothing dangerous)"; $p = $p + 1 } else { log-fail $"saved pasted content wrong: ($saved)"; $fl = $fl + 1 } } }
# ── Edit again: the saved Slate content seeds back into the surface ── wd-navigate $session_id $"(base-url)($ed_path)" if (wd-wait-for-element $session_id "#rich-editor" 30) { sleep 500ms let reseed = (try { wd-execute $session_id ($ed + 'var h=E.innerHTML;return JSON.stringify({h2:/<h2/.test(h)&&h.indexOf("HeadLine")>=0,b:/<(b|strong)[^>]*>BoldWord/.test(h),ul:/<ul/.test(h)&&h.indexOf("BulletItem")>=0,a:h.indexOf("example.com/e2e")>=0})') | from json } catch { null }) if ($reseed != null) and ($reseed.h2 and $reseed.b and $reseed.ul and $reseed.a) { log-ok "editor re-seeds the saved content (slate -> html round trip)"; $p = $p + 1 } else { log-fail $"re-opened editor lost content: ($reseed)"; $fl = $fl + 1 } # Append a line and save again: the second save must keep the earlier # formatting (the round trip is lossless under repeated edit cycles). wd-execute $session_id ($ed + 'endCaret();document.execCommand("insertParagraph");document.execCommand("insertText",false,"RoundTripTwo");return 1') | ignore sleep 300ms wd-execute $session_id 'var b=[...document.querySelectorAll(".editor-toolbar .btn")].find(function(x){var m=x.querySelector(".material-icons");return m&&m.textContent.trim()=="save"});if(b)b.click();return 1' | ignore if (wd-wait-y $session_id 'return location.search.indexOf("app=editor")<0?"y":"n"' 10000) { let second = (try { wd-execute $session_id ($gql + 'var GID="' + $grp.id + '";var r=gql("query($i:uuid!){node(id:$i){data}}",{i:GID});var c="";try{c=JSON.stringify(r.data.node.data.content)}catch(e){}return JSON.stringify({rt:c.indexOf("RoundTripTwo")>=0,h2:c.indexOf("heading-two")>=0,b:c.indexOf("\"bold\":true")>=0,li:c.indexOf("example.com/e2e")>=0})') | from json } catch { null }) if ($second != null) and ($second.rt and $second.h2 and $second.b and $second.li) { log-ok "second save appends without losing earlier formatting"; $p = $p + 1 } else { log-fail $"second save lost content: ($second)"; $fl = $fl + 1 } } else { log-fail "second save did not navigate away"; $fl = $fl + 1 } } else { log-fail "editor did not re-mount for the round-trip check"; $fl = $fl + 1 }
# ── Autosave: a typed draft persists after the debounce, with no Save click. # On a DRAFT, which a page is until it is submitted. A group is submitted # from the moment it is made, and autosave stays off for what is (the # editor says so in a banner), so the group above is no place to look. ── let dname = $"E2E draft ($t)" wd-navigate $session_id $"(base-url)/($grp.key)" sleep 800ms wd-folder-add $session_id "wiki/document" $dname | ignore let draft = (try { wd-execute $session_id ($gql + 'var P="' + $grp.id + '";var r=gql("query($p:uuid!,$n:String!){nodes(where:{parentId:{_eq:$p},name:{_eq:$n},mimeId:{_eq:\"wiki/document\"}}){id key}}",{p:P,n:"' + $dname + '"});var o={id:null,key:null};try{o.id=r.data.nodes[0].id;o.key=r.data.nodes[0].key}catch(e){}return JSON.stringify(o);') | from json } catch { {id: null, key: null} }) if ($draft.id | is-empty) { log-fail "draft page not created via the Add-content UI"; $fl = $fl + 1 } wd-navigate $session_id $"(base-url)/($grp.key)/($draft.key | default '')?app=editor" if ($draft.id | is-not-empty) and (wd-wait-for-element $session_id "#rich-editor" 30) { sleep 500ms wd-execute $session_id ($ed + 'endCaret();document.execCommand("insertText",false," AutosaveProbe");return 1') | ignore # AUTOSAVE_DEBOUNCE_MS is 2.5s; leave headroom for the mutation round trip. sleep 4500ms let still_editing = (wd-execute $session_id 'return location.search.indexOf("app=editor")>=0?"y":"n"') let auto = (wd-execute $session_id ($gql + 'var GID="' + $draft.id + '";var r=gql("query($i:uuid!){node(id:$i){data}}",{i:GID});var c="";try{c=JSON.stringify(r.data.node.data.content)}catch(e){}return c.indexOf("AutosaveProbe")>=0?"y":"n"')) if ($still_editing == "y") and ($auto == "y") { log-ok "debounced autosave persists the draft without Save"; $p = $p + 1 } else { log-fail $"autosave did not persist \(editing=($still_editing) saved=($auto))"; $fl = $fl + 1 } } else { log-fail "editor did not re-mount for the autosave check"; $fl = $fl + 1 }
# ── Teardown: delete the throwaway group + its permissions ── let td = (wd-teardown-context $session_id $gql $grp.id) if ($td.gone == true) { log-ok "cleaned up the editor-flow group + permissions"; $p = $p + 1 } else { log-fail $"cleanup incomplete, MANUAL CHECK: group ($grp.id) gone=($td.gone)"; $fl = $fl + 1 }
{ passed: $p, failed: $fl }}
# Create-group-from-home write-flow: the owner-only "new group/event" control on# the home hero (bound to the root node). Drives the dialog through the UI, then# verifies against the backend that the node was created as its OWN context with# the permission template seeded (so it is actually usable), and deletes it again.# Firefox-only; warn-skips on Servo. Self-cleaning on the live backend.def test-create-context [session_id: string, passed: int, failed: int]: nothing -> record<passed: int, failed: int> { mut p = $passed; mut fl = $failed log-info "" log-info "── Create group from home (write-flow) ──────────────────" if (servo-skip "create-group-from-home") { return { passed: $p, failed: $fl } } let GQL = (gql-url) let gql = (gql-prelude $GQL) let gname = $"E2E Group (date now | format date '%Y%m%d%H%M%S')"
# Home: open the owner-only "add group" dialog from the Groups list header # (found by its aria-label, so it works whether it renders in the home card or # the desktop drawer). wd-navigate $session_id $"(base-url)/" sleep 1sec if not (wd-wait-y $session_id 'return [...document.querySelectorAll("button.add-action")].some(function(b){return b.getAttribute("aria-label")=="New group"})?"y":"n"' 8000) { log-fail "no add-group control on the groups list (owner)"; $fl = $fl + 1 return { passed: $p, failed: $fl } } log-ok "add-group control shown on the groups list (owner)"; $p = $p + 1 wd-execute $session_id 'var b=[...document.querySelectorAll("button.add-action")].find(function(b){return b.getAttribute("aria-label")=="New group"});if(b)b.click();return 1' | ignore sleep 500ms # Fill the name and create (the per-list dialog has no type selector). wd-execute $session_id ('var ta=document.querySelector(".m3-dialog .text-field input");if(ta){ta.value="' + $gname + '";ta.dispatchEvent(new Event("input",{bubbles:true}))}return 1') | ignore sleep 400ms wd-execute $session_id 'var b=document.querySelector(".m3-dialog-actions .btn-primary");if(b)b.click();return 1' | ignore # Wait to navigate into the freshly created group (the URL leaves "/"). mut navigated = false for _ in 1..20 { let path = (wd-execute $session_id 'return location.pathname') if ($path != "/") and ($path != null) { $navigated = true; break } sleep 300ms } if $navigated { log-ok "navigated into the new group"; $p = $p + 1 } else { log-fail "did not navigate into the new group"; $fl = $fl + 1 } sleep 800ms
# Verify against the backend: the node exists as wiki/group, is its own # context, and carries the seeded permission template. let chk = (try { wd-execute $session_id ($gql + 'var r=gql("query($n:String!){nodes(where:{name:{_eq:$n},mimeId:{_eq:\"wiki/group\"}}){id contextId}}",{n:"' + $gname + '"});var id=null,ctx=null;try{id=r.data.nodes[0].id;ctx=r.data.nodes[0].contextId}catch(e){}var perms=0;if(id){var pr=gql("query($c:uuid!){permissions(where:{contextId:{_eq:$c}}){id}}",{c:id});try{perms=pr.data.permissions.length}catch(e){}}return JSON.stringify({id:id,selfCtx:(id!=null&&id==ctx),perms:perms});') | from json } catch { {id: null, selfCtx: false, perms: 0} }) if ($chk.id | is-empty) { log-fail "group not found in backend after create"; $fl = $fl + 1 } else { if ($chk.selfCtx == true) { log-ok "new group is its own context"; $p = $p + 1 } else { log-fail "new group context not set to self"; $fl = $fl + 1 } if (on-appview) { # No rows to seed: what may be made where is code. What a template gave # its maker is that the new group is theirs to run. let owns = (wd-execute $session_id ($gql + 'var r=xrpc("getNode",{id:"' + $chk.id + '"});return (r&&r.viewer&&r.viewer.is_context_owner)?"y":"n"')) if $owns == "y" { log-ok "whoever made the group runs it"; $p = $p + 1 } else { log-fail "the new group is not its maker's to run"; $fl = $fl + 1 } } else if ($chk.perms >= 10) { log-ok $"permission template seeded \(($chk.perms) rows\)"; $p = $p + 1 } else { log-fail $"permission template not seeded \(($chk.perms) rows\)"; $fl = $fl + 1 } }
# Teardown: delete the group's permissions + the node (it has no children yet). if ($chk.id | is-not-empty) { let td = (try { wd-execute $session_id ($gql + 'var ID="' + $chk.id + '";gql("mutation($c:uuid!){deletePermissions(where:{contextId:{_eq:$c}}){affected_rows}}",{c:ID});gql("mutation($i:uuid!){deleteNode(id:$i){id}}",{i:ID});var chk=gql("query($i:uuid!){node(id:$i){id}}",{i:ID});var gone=true;try{gone=!chk.data.node}catch(e){}return JSON.stringify({gone:gone});') | from json } catch { {gone: false} }) if ($td.gone == true) { log-ok "cleaned up the created group + permissions"; $p = $p + 1 } else { log-fail $"cleanup incomplete, MANUAL CHECK: group ($chk.id)"; $fl = $fl + 1 } }
{ passed: $p, failed: $fl }}
# Component CSS contracts for the M3 carousel and content image/lightbox. These# render from data the harness may not have (candidate photos, content images),# so verify the styling by injecting the exact markup the components emit and# reading computed styles (data-independent), plus screenshots for review.def test-components [session_id: string, passed: int, failed: int] { mut p = $passed mut fl = $failed log-info "" log-info "── M3 carousel + content image ──"
# Carousel: horizontal snap-scroll strip of rounded items. let cjs = 'var w=document.createElement("div"); w.id="__ctest_carousel"; var car=document.createElement("div"); car.className="m3-carousel"; car.setAttribute("aria-label","Candidates"); for(var i=0;i<5;i++){var a=document.createElement("a"); a.className="m3-carousel-item"; var ph=document.createElement("div"); ph.className="m3-carousel-placeholder"; var ic=document.createElement("span"); ic.className="material-icons"; ic.textContent="person"; ph.appendChild(ic); a.appendChild(ph); var lb=document.createElement("div"); lb.className="m3-carousel-label"; lb.textContent="Candidate "+(i+1); a.appendChild(lb); car.appendChild(a);} w.appendChild(car); document.body.appendChild(w); var cs=getComputedStyle(car); var it=car.querySelector(".m3-carousel-item"); var is=getComputedStyle(it); return JSON.stringify({snap:cs.scrollSnapType,overflowX:cs.overflowX,itemRadius:parseFloat(is.borderTopLeftRadius)||0,itemSnap:is.scrollSnapAlign,itemW:parseFloat(is.width)||0})' let craw = (wd-execute $session_id $cjs) let c = (try { $craw | from json } catch { null }) if $c == null { log-fail "carousel CSS probe returned non-JSON"; $fl = $fl + 1 } else if ($c.snap | str contains "x") and (($c.overflowX == "auto") or ($c.overflowX == "scroll")) and ($c.itemRadius > 0) and ($c.itemSnap | str contains "start") and ($c.itemW > 0) { log-ok $"carousel: horizontal snap-scroll, rounded items radius=($c.itemRadius)px width=($c.itemW)px"; $p = $p + 1 } else { log-fail $"carousel CSS off: ($craw)"; $fl = $fl + 1 } if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-execute $session_id 'var w=document.getElementById("__ctest_carousel"); if(w){w.style.cssText="position:fixed;top:80px;left:16px;width:540px;z-index:2000;background:var(--md-surface-container-low);border-radius:16px;padding-top:12px;box-shadow:0 2px 10px rgba(0,0,0,0.25)"; var h=document.createElement("div"); h.style.cssText="padding:4px 16px 4px;font-weight:600"; h.textContent="Candidates"; w.insertBefore(h,w.firstChild);} return 1' | ignore sleep 300ms wd-screenshot $session_id ($dir | path join "carousel.png") } wd-execute $session_id 'var w=document.getElementById("__ctest_carousel"); if(w)w.remove(); return 1' | ignore
# Content image: .zoomable is a capped, rounded thumbnail (not full-bleed). The # `min(100%, 20rem)` cap is left unresolved by Firefox's getComputedStyle, so # measure the RENDERED width inside a wide (900px) container instead. wd-execute $session_id 'var box=document.createElement("div"); box.id="__ctest_img"; box.style.cssText="width:900px"; var img=document.createElement("img"); img.className="zoomable"; img.src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22800%22 height=%22600%22%3E%3Crect width=%22800%22 height=%22600%22 fill=%22%23607d8b%22/%3E%3C/svg%3E"; box.appendChild(img); document.body.appendChild(box); return 1' | ignore sleep 400ms let iraw = (wd-execute $session_id 'var img=document.querySelector("#__ctest_img img.zoomable"); if(!img) return "none"; var r=img.getBoundingClientRect(); var s=getComputedStyle(img); return JSON.stringify({w:Math.round(r.width),radius:parseFloat(s.borderTopLeftRadius)||0})') let im = (try { $iraw | from json } catch { null }) if $im == null { log-fail $"image CSS probe returned non-JSON: ($iraw)"; $fl = $fl + 1 } else if ($im.w > 0) and ($im.w <= 340) and ($im.radius > 0) { log-ok $"content image is a capped rounded thumbnail, rendered width=($im.w)px in a 900px box"; $p = $p + 1 } else { log-fail $"content image not constrained: ($iraw)"; $fl = $fl + 1 } if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") # Inject the lightbox (as clicking the image would) and screenshot it. wd-execute $session_id 'var lb=document.createElement("div"); lb.className="image-lightbox"; var im=document.createElement("img"); im.className="image-lightbox-img"; im.src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22800%22 height=%22600%22%3E%3Crect width=%22800%22 height=%22600%22 fill=%22%23607d8b%22/%3E%3Ccircle cx=%22400%22 cy=%22300%22 r=%22160%22 fill=%22%23c2185b%22/%3E%3C/svg%3E"; lb.appendChild(im); var cb=document.createElement("button"); cb.className="image-lightbox-close btn-icon state-layer"; var ci=document.createElement("span"); ci.className="material-icons"; ci.textContent="close"; cb.appendChild(ci); lb.appendChild(cb); lb.id="__ctest_lb"; document.body.appendChild(lb); return 1' | ignore sleep 400ms wd-screenshot $session_id ($dir | path join "image-lightbox.png") wd-execute $session_id 'var l=document.getElementById("__ctest_lb"); if(l)l.remove(); return 1' | ignore } wd-execute $session_id 'var b=document.getElementById("__ctest_img"); if(b)b.remove(); return 1' | ignore
# Inline invitation in the groups list (accept / reject) — screenshot for review # (the test account has no pending invites, so inject the exact markup). if (($env | get -o WIKI_SHOTS | default "") == "1") { let dir = ($env | get -o WIKI_SHOTS_DIR | default "screenshots") wd-execute $session_id 'var card=document.createElement("div"); card.className="card"; card.id="__ctest_invite"; card.style.cssText="position:fixed;top:80px;left:16px;width:360px;z-index:2000"; var h=document.createElement("div"); h.className="card-header"; var ht=document.createElement("h3"); ht.className="title-medium"; ht.textContent="Groups"; h.appendChild(ht); card.appendChild(h); var list=document.createElement("div"); list.className="list"; function mk(nm,invited){var it=document.createElement("div"); it.className="list-item"; var av=document.createElement("div"); av.className="avatar small secondary"; var ai=document.createElement("span"); ai.className="material-icons"; ai.textContent="group"; av.appendChild(ai); it.appendChild(av); var tx=document.createElement("div"); tx.className="list-item-text"; var p1=document.createElement("div"); p1.className="list-item-primary"; p1.textContent=nm; tx.appendChild(p1); if(invited){var p2=document.createElement("div"); p2.className="list-item-secondary"; p2.textContent="Invited"; tx.appendChild(p2);} it.appendChild(tx); if(invited){var b1=document.createElement("button"); b1.className="btn-icon add-action state-layer"; var i1=document.createElement("span"); i1.className="material-icons"; i1.textContent="check"; b1.appendChild(i1); it.appendChild(b1); var b2=document.createElement("button"); b2.className="btn-icon state-layer"; var i2=document.createElement("span"); i2.className="material-icons"; i2.textContent="close"; b2.appendChild(i2); it.appendChild(b2);} return it;} list.appendChild(mk("Klimaudvalget",true)); list.appendChild(mk("Test",false)); list.appendChild(mk("Blog",false)); card.appendChild(list); document.body.appendChild(card); return 1' | ignore sleep 300ms wd-screenshot $session_id ($dir | path join "invited-item.png") wd-execute $session_id 'var c=document.getElementById("__ctest_invite"); if(c)c.remove(); return 1' | ignore }
{ passed: $p, failed: $fl }}
# ── Main ────────────────────────────────────────────────────────────────────
def main [ --timeout: int = 30 --verbose --keep --firefox # Drive real Firefox (via geckodriver) instead of Servo. Firefox # catches client-side routing / rendering bugs Servo masks. --shots # Capture PNG screenshots (light/dark x desktop/mobile) of key # screens to ./screenshots for visual review. --reuse # Reuse a `dx serve` already listening on the serve port instead of # rebuilding. Start one with `--keep` once, then pass --reuse to skip # the ~minute build on every subsequent run. --appview # The build on the AppView (`--features appview`), against a dev # AppView this starts. Needs no account: the authenticated tests # always run, signed in as the dev AppView's owner.] { let proj = $env.FILE_PWD if $shots { $env.WIKI_SHOTS = "1" $env.WIKI_SHOTS_DIR = ($proj | path join "screenshots") } # Record the driving engine so tests can skip checks headless Servo can't do # (it never fires window-resize events and only partially implements # contenteditable execCommand). These pass under real Firefox (--firefox), # which is the reference engine; on Servo they warn-skip instead of failing. $env.WIKI_ENGINE = (if $firefox { "firefox" } else { "servo" }) if $appview { $env.WIKI_BACKEND = "appview" } mut servo_pid = 0 mut server_pid = 0 mut api_pid = 0 mut session_id = "" mut passed = 0 mut failed = 0
# Preflight let servo = if $firefox { "" } else { (servo-bin) } if (not $firefox) and ($servo | is-empty) { log-fail "Servo not found (need `servo` in the dev shell)"; exit 2 } if $firefox and (which geckodriver | is-empty) and (which nix | is-empty) { log-fail "Firefox mode needs `geckodriver` + `firefox` on PATH, or `nix`"; exit 2 } for cmd in [dx curl jq] { if (which $cmd | is-empty) { log-fail $"Required command not found: ($cmd)"; exit 2 } }
cd $proj if $appview { $api_pid = (start-dev-appview) if $api_pid == 0 { exit 2 } } # Reuse a `dx serve` already listening on the serve port (skips the ~minute # rebuild) when --reuse is set and it answers; otherwise start a fresh one. let reuse_active = ($reuse and ((do -i { ^curl -sf -o /dev/null $"(base-url)/" } | complete).exit_code == 0)) mut serve_log = "" if $reuse_active { log-info $"Reusing `dx serve` already running on :($SERVE_PORT) — skipping build." } else { kill-port $SERVE_PORT # Start dx serve (debug build — the one Servo can run). log-info $"Starting `dx serve` on :($SERVE_PORT) \(first build may take a minute)..." $serve_log = (^mktemp /tmp/wiki-dx-XXXXXX.log | str trim) let serve = (if $appview { $"WIKI_APPVIEW_URL=(api-url) dx serve --features appview --interactive false --open false --port ($SERVE_PORT)" } else { $"dx serve --port ($SERVE_PORT)" }) $server_pid = (^bash -c $'($serve) > "($serve_log)" 2>&1 < /dev/null & echo $!' | str trim | into int)
# `dx serve` binds the port before it finishes the first wasm build, so wait # for the build to actually complete (its log announces it) — otherwise the # page loads with no wasm and nothing mounts. let srv_pid = $server_pid mut ready = false for _ in 1..181 { let built = (try { (open --raw $serve_log) | str contains "Build completed" } catch { false }) let up = ((do -i { ^curl -sf -o /dev/null $"(base-url)/" } | complete).exit_code == 0) if $built and $up { $ready = true; break } let alive = (do -i { ^kill -0 $srv_pid } | complete) if $alive.exit_code != 0 { log-fail "dx serve exited during build"; break } sleep 1sec } if not $ready { log-fail "dx serve did not finish its first build"; if ($serve_log | path exists) { print -e (open --raw $serve_log | lines | last 15 | str join "\n") }; do-cleanup $session_id $servo_pid $server_pid $reuse_active; exit 2 } log-info "dx serve ready (build complete)" }
kill-port $WD_PORT
# Start the WebDriver browser: Servo by default, or real Firefox (geckodriver) # with --firefox. let servo_log = (^mktemp /tmp/wiki-wd-XXXXXX.log | str trim) if $firefox { log-info $"Starting geckodriver + Firefox \(WebDriver on :($WD_PORT))..." $servo_pid = (^bash -c (gecko-cmd $servo_log) | str trim | into int) } else { log-info $"Starting Servo \(headless, WebDriver on :($WD_PORT))..." $servo_pid = (^bash -c $'($servo) --headless --webdriver=($WD_PORT) "about:blank" > "($servo_log)" 2>&1 & echo $!' | str trim | into int) }
mut wd_ready = false for _ in 1..101 { let check = (do -i { ^curl -sf $"(wd-url)/status" } | complete) if $check.exit_code == 0 { $wd_ready = true; break } sleep 300ms } if not $wd_ready { log-fail "WebDriver server did not become ready"; do-cleanup $session_id $servo_pid $server_pid $reuse_active; exit 2 }
let caps = if $firefox { '{"capabilities":{"alwaysMatch":{"moz:firefoxOptions":{"args":["-headless"]},"acceptInsecureCerts":true}}}' } else { '{"capabilities":{}}' } $session_id = (wd-new-session $caps) if ($session_id | is-empty) { log-fail "Failed to create WebDriver session"; do-cleanup $session_id $servo_pid $server_pid $reuse_active; exit 2 } wd-set-timeouts $session_id 20000 log-info $"Session: ($session_id)"
# Prime the origin once and clear any persisted session, so the first test # starts logged-out. A session left by an earlier run (or `--keep`) would # otherwise make `/` render the authenticated home and hide the login links # the shell smoke test asserts on. Doing it once here (rather than a second # in-test navigate) keeps each test to a single, race-free page load. wd-navigate $session_id $"(base-url)/" wd-wait-for-mount $session_id 120 | ignore wd-execute $session_id 'try{localStorage.clear()}catch(e){}; return "ok"' | ignore
# Run tests let r = (test-shell $session_id $timeout $passed $failed); $passed = $r.passed; $failed = $r.failed
let email = ($env | get -o WIKI_EMAIL | default "") let password = ($env | get -o WIKI_PASSWORD | default "") if $appview or (($email | is-not-empty) and ($password | is-not-empty)) { let r = (test-auth $session_id $email $password $timeout $passed $failed); $passed = $r.passed; $failed = $r.failed # Write-flow (create poll / vote / comment) needs the authed session and # only runs for real under Firefox; it self-cleans on the live backend. let r = (test-vote-flow $session_id $passed $failed); $passed = $r.passed; $failed = $r.failed # Create group/event from home (owner-only) — also Firefox-only + self-cleaning. let r = (test-create-context $session_id $passed $failed); $passed = $r.passed; $failed = $r.failed # Full editor feature pass (type / format / paste / save / re-edit / # autosave) on a hermetic group — Firefox-only + self-cleaning. let r = (test-editor-flow $session_id $passed $failed); $passed = $r.passed; $failed = $r.failed } else { log-info "" log-info "Skipping authenticated tests (set WIKI_EMAIL / WIKI_PASSWORD to enable)." }
# Component CSS contracts (carousel + content image/lightbox), data-independent. let r = (test-components $session_id $passed $failed); $passed = $r.passed; $failed = $r.failed
print -e "" if $verbose and ($servo_log | path exists) { log-info "--- Servo output ---"; print -e (open --raw $servo_log); log-info "--- end ---" }
# Reused servers are always left running (implicit --keep for the server). if $reuse_active { do-cleanup $session_id $servo_pid $server_pid true; rm -f $servo_log } else if not $keep { do-cleanup $session_id $servo_pid $server_pid false; rm -f $serve_log $servo_log }
let total = $passed + $failed if $failed == 0 { log-ok $"($total) tests: ($passed) passed, 0 failed" if $keep { log-info $"Left running — dx serve pid ($server_pid), Servo pid ($servo_pid)." } exit 0 } else { log-fail $"($total) tests: ($passed) passed, ($failed) failed" exit 1 }}