test(kanban): prove multiplayer milestone journey

req: ms/001

req: ms/002

req: ms/003
This commit is contained in:
slhx agent
2026-07-13 23:51:51 +02:00
parent 2f1b7703f9
commit 2a74a28d3b
4 changed files with 175 additions and 19 deletions
+7 -7
View File
File diff suppressed because one or more lines are too long
+33 -2
View File
@@ -1,8 +1,8 @@
use axum::extract::{Query, Request, State};
use axum::extract::{Form, Query, Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use futures_util::{stream, StreamExt};
@@ -500,6 +500,7 @@ async fn main() {
let ordinary_routes = Router::new()
.route("/", get(home).post(interact))
.route("/move", post(move_card_without_script))
.route("/events", get(events))
.route("/sync/broadcast", get(sync_broadcast))
.route("/sync/ack", get(sync_ack))
@@ -554,6 +555,36 @@ async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl
.fingerprint(ui::BUILD_FINGERPRINT)
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum MoveDirection {
Left,
Right,
}
#[derive(Deserialize)]
struct MoveCardForm {
card_id: u64,
direction: MoveDirection,
}
// req: accessibility/001 req: ms/001
async fn move_card_without_script(
State(state): State<Arc<AppState>>,
Form(command): Form<MoveCardForm>,
) -> Result<Redirect, StatusCode> {
let mut board = state.board.lock().unwrap();
let moved = update_card(&mut board, Some(command.card_id), |card| {
card.column = match command.direction {
MoveDirection::Left => card.column.saturating_sub(1),
MoveDirection::Right => (card.column + 1).min(COLUMNS.len() - 1),
};
});
moved
.then(|| Redirect::to("/"))
.ok_or(StatusCode::BAD_REQUEST)
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
@@ -1,10 +1,16 @@
<article class="card" +data-key="self.id">
<strong>{+ self.title +}</strong>
<menu>
<button h-if="self.left_disabled" type="button" data-hemx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</button>
<button h-else type="button" data-hemx-handle="move_left" +data-card-id="self.id">←</button>
<button h-if="self.right_disabled" type="button" data-hemx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
<button h-else type="button" data-hemx-handle="move_right" +data-card-id="self.id"></button>
<button h-if="self.left_disabled" type="button" aria-label="Move card left" data-hemx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</button>
<form h-else method="post" action="/move">
<input type="hidden" name="card_id" +value="self.id">
<button type="submit" name="direction" value="left" aria-label="Move card left" data-hemx-handle="move_left" +data-card-id="self.id"></button>
</form>
<button h-if="self.right_disabled" type="button" aria-label="Move card right" data-hemx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
<form h-else method="post" action="/move">
<input type="hidden" name="card_id" +value="self.id">
<button type="submit" name="direction" value="right" aria-label="Move card right" data-hemx-handle="move_right" +data-card-id="self.id">→</button>
</form>
<button type="button" data-hemx-handle="delete_card" +data-card-id="self.id">Delete</button>
</menu>
</article>
+125 -6
View File
@@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use thirtyfour::common::capabilities::firefox::FirefoxPreferences;
use thirtyfour::prelude::*;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
@@ -359,8 +360,9 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
}
#[tokio::test]
async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay(
) -> WebDriverResult<()> {
async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> {
// test req: ms/001 req: ms/002 req: ms/003 req: v1_release/001
// test req: accessibility/001 req: accessibility/002
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -382,6 +384,13 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
.map(PathBuf::from)
.unwrap_or_else(|| workspace.join("target"));
let host_binary = target_dir.join("debug/hemx-kanban-example");
let host_port = available_port();
let host_addr = format!("127.0.0.1:{host_port}");
let mut host_command = Command::new(&host_binary);
host_command.env("HEMX_KANBAN_ADDR", &host_addr);
let _host = ProcessGuard::start(host_command, &host_addr);
let host_url = format!("http://{host_addr}");
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js");
let mut server = StaticServer::start(
@@ -395,13 +404,97 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
let webdriver_port = available_port();
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
let webdriver_url = format!("http://{webdriver_addr}");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg(webdriver_port.to_string());
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
let mut no_script_preferences = FirefoxPreferences::new();
no_script_preferences.set("javascript.enabled", false)?;
let mut no_script_caps = DesiredCapabilities::firefox();
no_script_caps.set_headless()?;
no_script_caps.set_preferences(no_script_preferences)?;
let no_script_driver = WebDriver::new(&webdriver_url, no_script_caps).await?;
no_script_driver.goto(&host_url).await?;
no_script_driver
.find(By::XPath(
"//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']",
))
.await?
.click()
.await?;
let moved_without_script = no_script_driver
.find(By::XPath(
"//section[contains(@class,'column')][h2='Doing']//strong[text()='Write requirements']",
))
.await;
let _ = no_script_driver.quit().await;
moved_without_script?;
let mut caps = DesiredCapabilities::firefox();
caps.set_headless()?;
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
let driver = WebDriver::new(&webdriver_url, caps).await?;
let result = async {
driver.goto(&host_url).await?;
wait_until(&driver, "return Boolean(window.hemx)").await?;
let optional_asset = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
fetch('/optional-avatar.webp')
.then((response) => done({ status: response.status, runtime: Boolean(window.hemx) }))
.catch((error) => done({ error: String(error) }));
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(optional_asset["status"], 404);
assert_eq!(optional_asset["runtime"], true);
driver
.find(By::XPath(
"//article[.//strong[text()='Write requirements']]//button[@name='direction' and @value='right']",
))
.await?
.send_keys(Key::Enter)
.await?;
wait_until(
&driver,
"return [...document.querySelectorAll('.column')].find((column) => column.querySelector('h2')?.textContent === 'Done')?.textContent.includes('Write requirements')",
)
.await?;
let presence = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const root = document.querySelector('[data-hemx-root]');
const source = new EventSource('/sync/broadcast?channel=board&action=join&member=milestone-peer');
const timeout = setTimeout(() => { source.close(); done({ error: 'presence timed out' }); }, 5000);
source.addEventListener('hemx', (event) => {
clearTimeout(timeout);
const normalized = event.data.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
window.hemx.applyBatch(bytes.buffer, root);
source.close();
done({ text: document.body.textContent });
});
source.onerror = () => { clearTimeout(timeout); source.close(); done({ error: 'presence failed' }); };
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(presence["error"].is_null(), "presence failed: {presence}");
assert!(
presence["text"].as_str().unwrap_or_default().contains("tick #1"),
"presence projection was not applied: {presence}"
);
driver.goto(&server.url()).await?;
wait_until(
&driver,
@@ -424,7 +517,12 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
Vec::new(),
)
.await?;
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
driver
.execute(
"const transfer = new DataTransfer(); const card = document.querySelector('[data-key=\"1\"]'); const drop = document.querySelector('[data-hemx-client-event=\"drop\"]'); card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: transfer })); drop.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); return true",
Vec::new(),
)
.await?;
wait_until(
&driver,
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
@@ -485,7 +583,7 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
assert_eq!(restored["order"], "2|1");
assert_eq!(restored["count"], "1");
assert!(restored["error"].is_null());
assert_eq!(restored["notice"], "Moved 1 with click");
assert_eq!(restored["notice"], "Moved 1 with drop");
let app_addr = server.address.to_string();
let mut app_command = Command::new(&host_binary);
@@ -511,6 +609,8 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
const conflict = await conflictResponse.json();
const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }});
const rejection = await rejectionResponse.json();
const peerResponse = await fetch('/sync/commands?command_id=peer%3A1&card_id=2&column=done', {{ method: 'POST' }});
const peer = await peerResponse.json();
const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json();
const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
const open = indexedDB.open('hemx-kanban-v1');
@@ -523,6 +623,8 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
conflict,
rejectionStatus: rejectionResponse.status,
rejection,
peerStatus: peerResponse.status,
peer,
snapshot,
history,
queueCount: count.result,
@@ -552,10 +654,15 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
assert_eq!(convergence["rejectionStatus"], 400);
assert_eq!(convergence["rejection"]["kind"], "invalid-command");
assert_eq!(convergence["rejection"]["error"], "unknown card_id");
assert_eq!(convergence["peerStatus"], 200);
assert_eq!(convergence["peer"]["commandId"], "peer:1");
assert_eq!(convergence["peer"]["serverSequence"], 2);
assert_eq!(convergence["queueCount"], 0);
assert_eq!(convergence["snapshot"]["serverSequence"], 1);
assert_eq!(convergence["snapshot"]["serverSequence"], 2);
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
assert_eq!(convergence["snapshot"]["cards"][1]["id"], 2);
assert_eq!(convergence["snapshot"]["cards"][1]["column"], "done");
assert_eq!(
convergence["history"]
.as_str()
@@ -612,6 +719,18 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
"restore: unsupported durable command future:2"
);
assert_eq!(rejected["ready"], false);
let delete_commands = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete_commands.click().await?;
assert_eq!(delete_commands.text().await?, "Confirm delete commands");
delete_commands.click().await?;
wait_until(
&driver,
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-kanban-command-ready') === true && root.getAttribute('data-kanban-command-count') === '0' && !root.hasAttribute('data-kanban-command-error')",
)
.await?;
Ok(())
}
.await;