feat(kanban): retry explicit keep-local choice
req: sync/009 req: sync/010 req: sync/011 req: sync/016
This commit is contained in:
@@ -1704,6 +1704,248 @@ async fn missing_history_rebase_and_user_conflict_resolution_preserve_suffix() -
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> WebDriverResult<()> {
|
||||
// test req: sync/009 req: sync/010 req: sync/011 req: sync/016
|
||||
let app_port = available_port();
|
||||
let app_addr = format!("127.0.0.1:{app_port}");
|
||||
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
|
||||
app_command
|
||||
.env("HEMX_KANBAN_ADDR", &app_addr)
|
||||
.env("HEMX_KANBAN_RETAINED_AFTER", "1");
|
||||
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready hemx-kanban");
|
||||
|
||||
let webdriver_port = available_port();
|
||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready geckodriver");
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
|
||||
let result = async {
|
||||
driver.goto(&format!("http://{app_addr}/")).await?;
|
||||
let seeded_server = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const command = (id, card, column = 'done') => fetch(`/sync/commands?command_id=${encodeURIComponent(id)}&card_id=${card}&column=${column}`, { method: 'POST' });
|
||||
(async () => {
|
||||
const statuses = [];
|
||||
statuses.push((await command('keep:1', 1)).status);
|
||||
statuses.push((await command('keep:2', 2)).status);
|
||||
statuses.push((await command('keep:3', 2)).status);
|
||||
statuses.push((await command('keep:external', 2, 'doing')).status);
|
||||
done({ statuses });
|
||||
})().catch((error) => done({ error: String(error) }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(seeded_server["statuses"], serde_json::json!([200, 200, 200, 200]));
|
||||
|
||||
let seeded_local = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const open = indexedDB.open('hemx-kanban-v1', 3);
|
||||
open.onupgradeneeded = () => {
|
||||
const database = open.result;
|
||||
const commands = database.createObjectStore('commands', { keyPath: 'id' });
|
||||
commands.createIndex('byAccountPartition', 'accountPartition');
|
||||
database.createObjectStore('meta');
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
const commands = tx.objectStore('commands');
|
||||
commands.add({
|
||||
id: 'keep:3', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'keep', session: 'keep-session',
|
||||
causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
commands.add({
|
||||
id: 'keep:4', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'keep', session: 'keep-session',
|
||||
causal: 4, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
};
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(seeded_local["seeded"], true);
|
||||
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'conflicted'",
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.execute(
|
||||
r#"
|
||||
window.__keepRejections = 0;
|
||||
window.__keepFailures = 0;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init = {}) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url, location.href);
|
||||
if (url.pathname === '/sync/commands' && url.searchParams.get('command_id') === 'keep:3:keep:4') {
|
||||
if (window.__keepRejections < 1) {
|
||||
window.__keepRejections += 1;
|
||||
return Promise.resolve(new Response(JSON.stringify({ kind: 'command-conflict', error: 'injected stale keep-local decision' }), {
|
||||
status: 409,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
if (window.__keepFailures < 3) {
|
||||
window.__keepFailures += 1;
|
||||
return Promise.resolve(new Response(JSON.stringify({ kind: 'transport-failure', error: 'injected keep-local retry' }), {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
return true;
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.find(By::Css("[data-sync-keep-local]"))
|
||||
.await?
|
||||
.click()
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'resolution-rejected'",
|
||||
)
|
||||
.await?;
|
||||
let rejected = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const root = document.querySelector('[data-kanban-sync]');
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll();
|
||||
request.onsuccess = () => done({
|
||||
resolution: root.getAttribute('data-sync-conflict-resolution'),
|
||||
resolutionDisabled: root.querySelector('[data-sync-keep-local]').disabled,
|
||||
pending: root.getAttribute('data-sync-pending-count'),
|
||||
rejections: window.__keepRejections,
|
||||
commands: request.result.sort((left, right) => left.causal - right.causal).map(({ id, causal, cardId }) => ({ id, causal, cardId })),
|
||||
});
|
||||
};
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(rejected["resolution"], "keep-local-rejected");
|
||||
assert_eq!(rejected["resolutionDisabled"], false);
|
||||
assert_eq!(rejected["pending"], "2");
|
||||
assert_eq!(rejected["rejections"], 1);
|
||||
assert_eq!(
|
||||
rejected["commands"],
|
||||
serde_json::json!([
|
||||
{ "id": "keep:3", "causal": 3, "cardId": "2" },
|
||||
{ "id": "keep:4", "causal": 4, "cardId": "1" }
|
||||
])
|
||||
);
|
||||
|
||||
driver
|
||||
.find(By::Css("[data-sync-keep-local]"))
|
||||
.await?
|
||||
.click()
|
||||
.await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'offline'",
|
||||
)
|
||||
.await?;
|
||||
let retrying = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
const root = document.querySelector('[data-kanban-sync]');
|
||||
const open = indexedDB.open('hemx-kanban-v1');
|
||||
open.onsuccess = () => {
|
||||
const request = open.result.transaction('commands', 'readonly').objectStore('commands').getAll();
|
||||
request.onsuccess = () => done({
|
||||
phase: root.getAttribute('data-sync-phase'),
|
||||
resolution: root.getAttribute('data-sync-conflict-resolution'),
|
||||
resolutionCommand: root.getAttribute('data-sync-resolution-command-id'),
|
||||
resolvedCommand: root.getAttribute('data-sync-resolved-command-id'),
|
||||
attempts: root.getAttribute('data-sync-attempts'),
|
||||
pending: root.getAttribute('data-sync-pending-count'),
|
||||
manualRetry: root.getAttribute('data-sync-manual-retry'),
|
||||
failures: window.__keepFailures,
|
||||
commands: request.result.sort((left, right) => left.causal - right.causal).map(({ id, causal, cardId }) => ({ id, causal, cardId })),
|
||||
});
|
||||
};
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(retrying["phase"], "offline");
|
||||
assert_eq!(retrying["resolution"], "keep-local-pending");
|
||||
assert_eq!(retrying["resolutionCommand"], "keep:3:keep:4");
|
||||
assert_eq!(retrying["resolvedCommand"], "keep:3");
|
||||
assert_eq!(retrying["attempts"], "3");
|
||||
assert_eq!(retrying["pending"], "2");
|
||||
assert_eq!(retrying["manualRetry"], "available");
|
||||
assert_eq!(retrying["failures"], 3);
|
||||
assert_eq!(
|
||||
retrying["commands"],
|
||||
serde_json::json!([
|
||||
{ "id": "keep:3", "causal": 3, "cardId": "2" },
|
||||
{ "id": "keep:4", "causal": 4, "cardId": "1" }
|
||||
])
|
||||
);
|
||||
|
||||
driver.find(By::Css("[data-sync-retry]")).await?.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'rebased' && root?.getAttribute('data-sync-pending-count') === '0'",
|
||||
)
|
||||
.await?;
|
||||
let resolved = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { resolution: root.getAttribute('data-sync-conflict-resolution'), resolvedCommand: root.getAttribute('data-sync-resolved-command-id'), pending: root.getAttribute('data-sync-pending-count'), ackSequence: root.getAttribute('data-sync-ack-sequence'), canonicalColumn: root.getAttribute('data-sync-canonical-column'), status: root.querySelector('[role=status]').textContent }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(resolved["resolution"], "kept-local-change");
|
||||
assert_eq!(resolved["resolvedCommand"], "keep:3");
|
||||
assert_eq!(resolved["pending"], "0");
|
||||
assert_eq!(resolved["ackSequence"], "6");
|
||||
assert_eq!(resolved["canonicalColumn"], "done");
|
||||
assert_eq!(
|
||||
resolved["status"],
|
||||
"Canonical snapshot 6 already satisfies keep:4; committed and removed the pending command."
|
||||
);
|
||||
assert_eq!(command_count(&driver).await?, 0);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let quit = driver.quit().await;
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_acknowledgement_survives_server_restart() -> WebDriverResult<()> {
|
||||
// test req: sync/001 req: sync/005 req: sync/007 req: sync/008 req: sync/013
|
||||
|
||||
Reference in New Issue
Block a user