test(kanban): prove multiplayer milestone journey
req: ms/001 req: ms/002 req: ms/003
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
use axum::extract::{Query, Request, State};
|
use axum::extract::{Form, Query, Request, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
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::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
@@ -500,6 +500,7 @@ async fn main() {
|
|||||||
|
|
||||||
let ordinary_routes = Router::new()
|
let ordinary_routes = Router::new()
|
||||||
.route("/", get(home).post(interact))
|
.route("/", get(home).post(interact))
|
||||||
|
.route("/move", post(move_card_without_script))
|
||||||
.route("/events", get(events))
|
.route("/events", get(events))
|
||||||
.route("/sync/broadcast", get(sync_broadcast))
|
.route("/sync/broadcast", get(sync_broadcast))
|
||||||
.route("/sync/ack", get(sync_ack))
|
.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)
|
.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 {
|
async fn runtime() -> impl IntoResponse {
|
||||||
runtime_js()
|
runtime_js()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<article class="card" +data-key="self.id">
|
<article class="card" +data-key="self.id">
|
||||||
<strong>{+ self.title +}</strong>
|
<strong>{+ self.title +}</strong>
|
||||||
<menu>
|
<menu>
|
||||||
<button h-if="self.left_disabled" type="button" data-hemx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</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>
|
||||||
<button h-else type="button" data-hemx-handle="move_left" +data-card-id="self.id">←</button>
|
<form h-else method="post" action="/move">
|
||||||
<button h-if="self.right_disabled" type="button" data-hemx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
|
<input type="hidden" name="card_id" +value="self.id">
|
||||||
<button h-else type="button" data-hemx-handle="move_right" +data-card-id="self.id">→</button>
|
<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>
|
<button type="button" data-hemx-handle="delete_card" +data-card-id="self.id">Delete</button>
|
||||||
</menu>
|
</menu>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+125
-6
@@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use thirtyfour::common::capabilities::firefox::FirefoxPreferences;
|
||||||
use thirtyfour::prelude::*;
|
use thirtyfour::prelude::*;
|
||||||
|
|
||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
|
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]
|
#[tokio::test]
|
||||||
async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay(
|
async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDriverResult<()> {
|
||||||
) -> 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
|
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
|
||||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
.parent()
|
.parent()
|
||||||
@@ -382,6 +384,13 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
|
|||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| workspace.join("target"));
|
.unwrap_or_else(|| workspace.join("target"));
|
||||||
let host_binary = target_dir.join("debug/hemx-kanban-example");
|
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 (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
|
||||||
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||||
let mut server = StaticServer::start(
|
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_port = available_port();
|
||||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||||
|
let webdriver_url = format!("http://{webdriver_addr}");
|
||||||
let mut webdriver = Command::new("geckodriver");
|
let mut webdriver = Command::new("geckodriver");
|
||||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||||
let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr);
|
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();
|
let mut caps = DesiredCapabilities::firefox();
|
||||||
caps.set_headless()?;
|
caps.set_headless()?;
|
||||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
let driver = WebDriver::new(&webdriver_url, caps).await?;
|
||||||
let result = async {
|
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?;
|
driver.goto(&server.url()).await?;
|
||||||
wait_until(
|
wait_until(
|
||||||
&driver,
|
&driver,
|
||||||
@@ -424,7 +517,12 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
wait_until(
|
||||||
&driver,
|
&driver,
|
||||||
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
|
"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["order"], "2|1");
|
||||||
assert_eq!(restored["count"], "1");
|
assert_eq!(restored["count"], "1");
|
||||||
assert!(restored["error"].is_null());
|
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 app_addr = server.address.to_string();
|
||||||
let mut app_command = Command::new(&host_binary);
|
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 conflict = await conflictResponse.json();
|
||||||
const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }});
|
const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }});
|
||||||
const rejection = await rejectionResponse.json();
|
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 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 history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
|
||||||
const open = indexedDB.open('hemx-kanban-v1');
|
const open = indexedDB.open('hemx-kanban-v1');
|
||||||
@@ -523,6 +623,8 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
|
|||||||
conflict,
|
conflict,
|
||||||
rejectionStatus: rejectionResponse.status,
|
rejectionStatus: rejectionResponse.status,
|
||||||
rejection,
|
rejection,
|
||||||
|
peerStatus: peerResponse.status,
|
||||||
|
peer,
|
||||||
snapshot,
|
snapshot,
|
||||||
history,
|
history,
|
||||||
queueCount: count.result,
|
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["rejectionStatus"], 400);
|
||||||
assert_eq!(convergence["rejection"]["kind"], "invalid-command");
|
assert_eq!(convergence["rejection"]["kind"], "invalid-command");
|
||||||
assert_eq!(convergence["rejection"]["error"], "unknown card_id");
|
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["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]["id"], 1);
|
||||||
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
|
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!(
|
assert_eq!(
|
||||||
convergence["history"]
|
convergence["history"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -612,6 +719,18 @@ async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_repl
|
|||||||
"restore: unsupported durable command future:2"
|
"restore: unsupported durable command future:2"
|
||||||
);
|
);
|
||||||
assert_eq!(rejected["ready"], false);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
Reference in New Issue
Block a user