feat(sync): replay durable projections from Rust
req: ms/001 req: ms/002 req: ms/003
This commit is contained in:
+110
-159
@@ -361,7 +361,7 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
|
||||
|
||||
#[tokio::test]
|
||||
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: ms/001 req: ms/002 req: ms/003 req: v1_release/001 req: v1_release/002
|
||||
// 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"))
|
||||
@@ -399,7 +399,7 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
bootstrap,
|
||||
rendered,
|
||||
"kanban_client",
|
||||
Some(kanban_app_assets(&workspace)),
|
||||
Some(framework_sync_assets(&workspace)),
|
||||
);
|
||||
|
||||
let webdriver_port = available_port();
|
||||
@@ -498,22 +498,30 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
driver.goto(&server.url()).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root?.hasAttribute('data-hemx-sync-ready') === true",
|
||||
)
|
||||
.await?;
|
||||
let framework_only = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
Promise.all(['/app.js', '/offline.js'].map((path) => fetch(path).then((response) => response.status)))
|
||||
.then((statuses) => done({ statuses, scripts: [...document.scripts].map((script) => script.getAttribute('src')) }))
|
||||
.catch((error) => done({ error: String(error) }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(framework_only["statuses"], serde_json::json!([404, 404]));
|
||||
assert_eq!(
|
||||
framework_only["scripts"],
|
||||
serde_json::json!(["/hemx.js", "/hemx.client.js", "/hemx-sync.js"])
|
||||
);
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
const root = document.querySelector('[data-hemx-root]');
|
||||
window.__persistedCommand = null;
|
||||
root.addEventListener('kanban:command-persisted', (event) => {
|
||||
window.__persistedCommand = {
|
||||
detail: event.detail,
|
||||
order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'),
|
||||
};
|
||||
}, { once: true });
|
||||
return true;
|
||||
"#,
|
||||
"window.__durablePatch = null; document.addEventListener('hemx:sync-patch', (event) => { window.__durablePatch = event.detail; }, { once: true }); document.querySelector('[data-hemx-root]').setAttribute('data-sync-endpoint', '/unavailable'); return true",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
@@ -525,84 +533,50 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && window.__durablePatch !== null && root.getAttribute('data-hemx-sync-pending') === '1' && root.getAttribute('data-hemx-sync-error') === 'upload-404'",
|
||||
)
|
||||
.await?;
|
||||
let persisted = driver
|
||||
let command_id = driver
|
||||
.execute(
|
||||
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
|
||||
"return JSON.parse(window.__durablePatch).patch.idempotencyKey",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(persisted["persisted"]["order"], "1|2");
|
||||
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 2);
|
||||
assert_eq!(persisted["persisted"]["detail"]["targetColumn"], "done");
|
||||
assert_eq!(persisted["persisted"]["detail"]["causal"], 1);
|
||||
let actor = persisted["persisted"]["detail"]["actor"]
|
||||
.as_str()
|
||||
.expect("persisted actor");
|
||||
let session = persisted["persisted"]["detail"]["session"]
|
||||
.as_str()
|
||||
.expect("persisted session");
|
||||
assert!(!actor.is_empty());
|
||||
assert!(!session.is_empty());
|
||||
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
|
||||
assert_eq!(persisted["count"], "1");
|
||||
let offline_shell = driver
|
||||
.execute_async(
|
||||
"const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(offline_shell["controlled"], true, "{offline_shell}");
|
||||
assert_eq!(offline_shell["shell"], true, "{offline_shell}");
|
||||
assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1");
|
||||
|
||||
server.stop();
|
||||
assert!(!server.is_reachable(), "fixture server must be unreachable");
|
||||
driver
|
||||
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||
.await?;
|
||||
.expect("durable interaction identity")
|
||||
.to_owned();
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1' && root.getAttribute('data-hemx-sync-pending') === '0' && root.hasAttribute('data-hemx-sync-ack')",
|
||||
)
|
||||
.await?;
|
||||
let restored = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }",
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), notice: root.querySelector('[data-sid]').textContent, error: root.getAttribute('data-hemx-sync-error') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(restored["order"], "2|1");
|
||||
assert_eq!(restored["count"], "1");
|
||||
assert!(restored["error"].is_null());
|
||||
assert_eq!(restored["notice"], "Moved 1 with drop");
|
||||
assert!(restored["error"].is_null());
|
||||
|
||||
let app_addr = server.address.to_string();
|
||||
server.stop();
|
||||
assert!(!server.is_reachable(), "fixture server must be stopped before app restart");
|
||||
let mut app_command = Command::new(&host_binary);
|
||||
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
|
||||
let _app = ProcessGuard::start(app_command, &app_addr);
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
|
||||
)
|
||||
.await?;
|
||||
let command_id = persisted["persisted"]["detail"]["id"]
|
||||
.as_str()
|
||||
.expect("persisted command id");
|
||||
let duplicate_script = format!(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const commandId = {command_id:?};
|
||||
(async () => {{
|
||||
const initialResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
|
||||
const initial = await initialResponse.json();
|
||||
const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
|
||||
const duplicate = await duplicateResponse.json();
|
||||
const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }});
|
||||
@@ -613,23 +587,20 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
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');
|
||||
open.onsuccess = () => {{
|
||||
const count = open.result.transaction('commands', 'readonly').objectStore('commands').count();
|
||||
count.onsuccess = () => done({{
|
||||
duplicateStatus: duplicateResponse.status,
|
||||
duplicate,
|
||||
conflictStatus: conflictResponse.status,
|
||||
conflict,
|
||||
rejectionStatus: rejectionResponse.status,
|
||||
rejection,
|
||||
peerStatus: peerResponse.status,
|
||||
peer,
|
||||
snapshot,
|
||||
history,
|
||||
queueCount: count.result,
|
||||
}});
|
||||
}};
|
||||
done({{
|
||||
initialStatus: initialResponse.status,
|
||||
initial,
|
||||
duplicateStatus: duplicateResponse.status,
|
||||
duplicate,
|
||||
conflictStatus: conflictResponse.status,
|
||||
conflict,
|
||||
rejectionStatus: rejectionResponse.status,
|
||||
rejection,
|
||||
peerStatus: peerResponse.status,
|
||||
peer,
|
||||
snapshot,
|
||||
history,
|
||||
}});
|
||||
}})().catch((error) => done({{ error: String(error), stack: error.stack }}));
|
||||
"#
|
||||
);
|
||||
@@ -642,6 +613,9 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
convergence.get("error").is_none(),
|
||||
"sync convergence failed: {convergence}"
|
||||
);
|
||||
assert_eq!(convergence["initialStatus"], 200);
|
||||
assert_eq!(convergence["initial"]["commandId"], command_id);
|
||||
assert_eq!(convergence["initial"]["serverSequence"], 1);
|
||||
assert_eq!(convergence["duplicateStatus"], 200);
|
||||
assert_eq!(convergence["duplicate"]["commandId"], command_id);
|
||||
assert_eq!(convergence["duplicate"]["serverSequence"], 1);
|
||||
@@ -657,7 +631,6 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
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"], 2);
|
||||
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
|
||||
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
|
||||
@@ -667,70 +640,11 @@ async fn multiplayer_kanban_milestone_journey_recovers_and_converges() -> WebDri
|
||||
convergence["history"]
|
||||
.as_str()
|
||||
.expect("acknowledgement history")
|
||||
.matches(command_id)
|
||||
.matches(&command_id)
|
||||
.count(),
|
||||
1,
|
||||
"duplicate replay emitted another acknowledgement: {convergence}"
|
||||
);
|
||||
|
||||
driver.goto(&format!("http://{app_addr}/")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
window.__futureCommandStored = false;
|
||||
const request = indexedDB.open('hemx-kanban-v1');
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'future:2', schemaVersion: 3, accountPartition: 'demo:demo', actor: 'future', session: 'future',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => { window.__futureCommandStored = true; };
|
||||
};
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
||||
driver
|
||||
.execute("window.__reloadPending = true; location.reload()", Vec::new())
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-error') === true",
|
||||
)
|
||||
.await?;
|
||||
let rejected = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
rejected["error"],
|
||||
"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;
|
||||
@@ -1744,15 +1658,23 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
struct AppAssets {
|
||||
module: PathBuf,
|
||||
service_worker: PathBuf,
|
||||
module: Option<PathBuf>,
|
||||
service_worker: Option<PathBuf>,
|
||||
sync_runtime: PathBuf,
|
||||
}
|
||||
|
||||
fn kanban_app_assets(workspace: &Path) -> AppAssets {
|
||||
AppAssets {
|
||||
module: workspace.join("examples/kanban/static/command-log.js"),
|
||||
service_worker: workspace.join("examples/kanban/static/offline.js"),
|
||||
module: Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||
service_worker: Some(workspace.join("examples/kanban/static/offline.js")),
|
||||
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||
}
|
||||
}
|
||||
|
||||
fn framework_sync_assets(workspace: &Path) -> AppAssets {
|
||||
AppAssets {
|
||||
module: None,
|
||||
service_worker: None,
|
||||
sync_runtime: workspace.join("hemx-sync/runtime/hemx-sync.js"),
|
||||
}
|
||||
}
|
||||
@@ -1841,7 +1763,7 @@ fn serve(
|
||||
let (content_type, body) = match path {
|
||||
"/" => (
|
||||
"text/html; charset=utf-8",
|
||||
fixture_html(rendered, app_assets.is_some()).into_bytes(),
|
||||
fixture_html(rendered, app_assets).into_bytes(),
|
||||
),
|
||||
"/hemx.js" => (
|
||||
"text/javascript; charset=utf-8",
|
||||
@@ -1859,15 +1781,36 @@ fn serve(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(bootstrap).expect("read generated client bootstrap"),
|
||||
),
|
||||
"/app.js" if app_assets.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(&app_assets.expect("checked app assets").module).expect("read app module"),
|
||||
),
|
||||
"/offline.js" if app_assets.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(&app_assets.expect("checked app assets").service_worker)
|
||||
"/app.js"
|
||||
if app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.is_some() =>
|
||||
{
|
||||
(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(
|
||||
app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.expect("checked app module"),
|
||||
)
|
||||
.expect("read app module"),
|
||||
)
|
||||
}
|
||||
"/offline.js"
|
||||
if app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.is_some() =>
|
||||
{
|
||||
(
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(
|
||||
app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.expect("checked service worker"),
|
||||
)
|
||||
.expect("read service worker"),
|
||||
),
|
||||
)
|
||||
}
|
||||
"/hemx-sync.js" if app_assets.is_some() => (
|
||||
"text/javascript; charset=utf-8",
|
||||
fs::read(&app_assets.expect("checked app assets").sync_runtime)
|
||||
@@ -1903,8 +1846,14 @@ fn serve(
|
||||
let status = if path == "/"
|
||||
|| path == "/hemx.js"
|
||||
|| path == "/hemx.client.js"
|
||||
|| ((path == "/app.js"
|
||||
|| path == "/offline.js"
|
||||
|| (((path == "/app.js"
|
||||
&& app_assets
|
||||
.and_then(|assets| assets.module.as_ref())
|
||||
.is_some())
|
||||
|| (path == "/offline.js"
|
||||
&& app_assets
|
||||
.and_then(|assets| assets.service_worker.as_ref())
|
||||
.is_some())
|
||||
|| path == "/hemx-sync.js"
|
||||
|| path == "/sync/context"
|
||||
|| path == "/sync/patches")
|
||||
@@ -1921,11 +1870,13 @@ fn serve(
|
||||
let _ = stream.write_all(&body);
|
||||
}
|
||||
|
||||
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||
let app_module = if has_app_module {
|
||||
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
||||
} else {
|
||||
""
|
||||
fn fixture_html(rendered: &str, app_assets: Option<&AppAssets>) -> String {
|
||||
let app_module = match app_assets {
|
||||
Some(assets) if assets.module.is_some() => {
|
||||
"<script type=\"module\" src=\"/app.js\"></script><script type=\"module\" src=\"/hemx-sync.js\"></script>"
|
||||
}
|
||||
Some(_) => "<script type=\"module\" src=\"/hemx-sync.js\"></script>",
|
||||
None => "",
|
||||
};
|
||||
format!(
|
||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script>{app_module}</body></html>"
|
||||
|
||||
Reference in New Issue
Block a user