feat(kanban): persist local reorder commands
req: local/001\nreq: local/002\nreq: local/003\nreq: local/004\nreq: sync/009
This commit is contained in:
@@ -33,11 +33,11 @@ encryption, retention, backup, and deployment policy remain host concerns.
|
|||||||
## Slice 3 — durable offline command log
|
## Slice 3 — durable offline command log
|
||||||
|
|
||||||
- [ ] **User value:** an opted-in Kanban mutation remains available after network loss and browser reload without storing DOM patches as truth.
|
- [ ] **User value:** an opted-in Kanban mutation remains available after network loss and browser reload without storing DOM patches as truth.
|
||||||
- **State:** Blocked by Slice 2.
|
- **State:** In progress — the first app-owned `reorder_card` command is transactionally persisted with schema, actor, session, causal id, and app payload before projection; reload restores the projection from commands, while unknown schemas stop with an explicit diagnostic. Offline shell reload, export/delete/reset, quota/corruption recovery, bounded replay, and performance proof remain.
|
||||||
- **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal.
|
- **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal.
|
||||||
- **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core.
|
- **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core.
|
||||||
- **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `performance/006`.
|
- **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `performance/006`.
|
||||||
- **Proof:** browser scenario mutates offline, reloads, restores the projection, exports/deletes/reset data, and fails recoverably under quota, corruption, and unknown schema without claiming durability after persistence failure.
|
- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, current-version replay after reload, stable identity metadata, and explicit unknown-schema refusal in real Firefox/WASM. The completed slice proof must additionally mutate and reload offline, export/delete/reset data, and fail recoverably under quota and corruption without claiming durability after persistence failure.
|
||||||
|
|
||||||
## Slice 4 — authoritative reconnect and convergence
|
## Slice 4 — authoritative reconnect and convergence
|
||||||
|
|
||||||
|
|||||||
+75
-10
@@ -1,25 +1,90 @@
|
|||||||
#[hemx::surface]
|
#[hemx::surface]
|
||||||
pub mod ui {}
|
pub mod ui {}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct CardId(String);
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
struct ReorderCommand {
|
||||||
|
card: CardId,
|
||||||
|
input_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
struct CardReordered {
|
||||||
|
card: CardId,
|
||||||
|
input_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
struct BoardProjection {
|
||||||
|
first: CardId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
struct ProjectedReorder {
|
||||||
|
card: CardId,
|
||||||
|
before: Option<CardId>,
|
||||||
|
input_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
impl ReorderCommand {
|
||||||
|
fn from_client(event: hemx::wasm::ClientEvent) -> Self {
|
||||||
|
Self {
|
||||||
|
card: CardId(
|
||||||
|
event
|
||||||
|
.value
|
||||||
|
.filter(|card| !card.is_empty())
|
||||||
|
.unwrap_or_else(|| "1".into()),
|
||||||
|
),
|
||||||
|
input_kind: event.kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decide(self) -> CardReordered {
|
||||||
|
CardReordered {
|
||||||
|
card: self.card,
|
||||||
|
input_kind: self.input_kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "client")]
|
||||||
|
impl BoardProjection {
|
||||||
|
fn restore(state: hemx::wasm::ClientState) -> Self {
|
||||||
|
Self {
|
||||||
|
first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(self, event: CardReordered) -> ProjectedReorder {
|
||||||
|
let before = (event.card != self.first).then_some(self.first);
|
||||||
|
ProjectedReorder {
|
||||||
|
card: event.card,
|
||||||
|
before,
|
||||||
|
input_kind: event.input_kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
#[hemx::handler(client)]
|
#[hemx::handler(client)]
|
||||||
pub fn reorder_card(
|
pub fn reorder_card(
|
||||||
event: hemx::wasm::ClientEvent,
|
event: hemx::wasm::ClientEvent,
|
||||||
state: hemx::wasm::ClientState,
|
state: hemx::wasm::ClientState,
|
||||||
) -> impl hemx::IntoEffect {
|
) -> impl hemx::IntoEffect {
|
||||||
let card = event.value.unwrap_or_else(|| "1".to_owned());
|
let projected =
|
||||||
let order = state.encoded.split('|').collect::<Vec<_>>();
|
BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide());
|
||||||
let move_effect = if card == order.first().copied().unwrap_or("1") {
|
let card = projected.card.0;
|
||||||
ui::client_board::client_cards.move_to_end(card.clone())
|
let move_effect = match projected.before {
|
||||||
} else {
|
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
|
||||||
ui::client_board::client_cards.move_before(
|
None => ui::client_board::client_cards.move_to_end(card.clone()),
|
||||||
card.clone(),
|
|
||||||
order.first().copied().unwrap_or("1").to_owned(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
vec![
|
vec![
|
||||||
move_effect,
|
move_effect,
|
||||||
ui::client_board::client_notice.text(format!("Moved {card} with {}", event.kind)),
|
ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
const DATABASE = "hemx-kanban-v1";
|
||||||
|
const COMMANDS = "commands";
|
||||||
|
const META = "meta";
|
||||||
|
const COMMAND_SCHEMA = 1;
|
||||||
|
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
|
||||||
|
|
||||||
|
function result(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.addEventListener("success", () => resolve(request.result), { once: true });
|
||||||
|
request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function completed(transaction) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
transaction.addEventListener("complete", resolve, { once: true });
|
||||||
|
transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true });
|
||||||
|
transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCommandLog() {
|
||||||
|
const request = indexedDB.open(DATABASE, 1);
|
||||||
|
request.addEventListener("upgradeneeded", () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" });
|
||||||
|
if (!database.objectStoreNames.contains(META)) database.createObjectStore(META);
|
||||||
|
});
|
||||||
|
return result(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientReady(root) {
|
||||||
|
if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (!root.hasAttribute("data-hemx-client-ready")) return;
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
observer.observe(root, { attributes: true, attributeFilter: ["data-hemx-client-ready"] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableSession() {
|
||||||
|
const key = "hemx-kanban-session-v1";
|
||||||
|
let session = sessionStorage.getItem(key);
|
||||||
|
if (!session) {
|
||||||
|
session = crypto.randomUUID();
|
||||||
|
sessionStorage.setItem(key, session);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendReorder(database, wire) {
|
||||||
|
const transaction = database.transaction([COMMANDS, META], "readwrite");
|
||||||
|
const done = completed(transaction);
|
||||||
|
const meta = transaction.objectStore(META);
|
||||||
|
const commands = transaction.objectStore(COMMANDS);
|
||||||
|
const actorRequest = result(meta.get("actor"));
|
||||||
|
const causalRequest = result(meta.get("causal"));
|
||||||
|
const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]);
|
||||||
|
const actor = storedActor || crypto.randomUUID();
|
||||||
|
const causal = (storedCausal || 0) + 1;
|
||||||
|
const command = {
|
||||||
|
id: `${actor}:${causal}`,
|
||||||
|
schemaVersion: COMMAND_SCHEMA,
|
||||||
|
actor,
|
||||||
|
session: stableSession(),
|
||||||
|
causal,
|
||||||
|
kind: "reorder_card",
|
||||||
|
cardId: String(wire[2] || "1"),
|
||||||
|
eventKind: String(wire[1] || "click"),
|
||||||
|
key: wire[4] ? String(wire[4]) : null,
|
||||||
|
};
|
||||||
|
meta.put(actor, "actor");
|
||||||
|
meta.put(causal, "causal");
|
||||||
|
commands.add(command);
|
||||||
|
const count = await result(commands.count());
|
||||||
|
await done;
|
||||||
|
return { command, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storedCommands(database) {
|
||||||
|
const transaction = database.transaction(COMMANDS, "readonly");
|
||||||
|
const done = completed(transaction);
|
||||||
|
const commands = await result(transaction.objectStore(COMMANDS).getAll());
|
||||||
|
await done;
|
||||||
|
return commands.sort((left, right) => left.causal - right.causal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(command) {
|
||||||
|
if (command.schemaVersion !== COMMAND_SCHEMA || command.kind !== "reorder_card" || !command.id || !command.actor || !command.session || !command.cardId || !Number.isSafeInteger(command.causal)) {
|
||||||
|
throw new Error(`unsupported durable command ${command && command.id ? command.id : "record"}`);
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function project(root, wasmHandler, command) {
|
||||||
|
const checked = validate(command);
|
||||||
|
const batch = await wasmHandler(
|
||||||
|
1,
|
||||||
|
checked.eventKind,
|
||||||
|
checked.cardId,
|
||||||
|
undefined,
|
||||||
|
checked.key || undefined,
|
||||||
|
1,
|
||||||
|
root.getAttribute("data-hemx-st") || "",
|
||||||
|
);
|
||||||
|
if (!(batch instanceof Uint8Array)) throw new Error("reorder_card returned an invalid effect batch");
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(root, stage, error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
root.setAttribute("data-kanban-command-error", `${stage}: ${message}`);
|
||||||
|
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const root = document.querySelector(ROOT);
|
||||||
|
if (!root) return;
|
||||||
|
const databasePromise = openCommandLog();
|
||||||
|
await clientReady(root);
|
||||||
|
let wasmHandler;
|
||||||
|
const durableHandler = async (...wire) => {
|
||||||
|
let command;
|
||||||
|
let count;
|
||||||
|
try {
|
||||||
|
({ command, count } = await appendReorder(await databasePromise, wire));
|
||||||
|
} catch (error) {
|
||||||
|
report(root, "persist", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
root.setAttribute("data-kanban-command-count", String(count));
|
||||||
|
root.dispatchEvent(new CustomEvent("kanban:command-persisted", {
|
||||||
|
detail: {
|
||||||
|
id: command.id,
|
||||||
|
schemaVersion: command.schemaVersion,
|
||||||
|
actor: command.actor,
|
||||||
|
session: command.session,
|
||||||
|
causal: command.causal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
return await project(root, wasmHandler, command);
|
||||||
|
} catch (error) {
|
||||||
|
report(root, "project", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
||||||
|
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
||||||
|
const database = await databasePromise;
|
||||||
|
try {
|
||||||
|
const commands = await storedCommands(database);
|
||||||
|
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||||
|
root.setAttribute("data-kanban-command-count", String(commands.length));
|
||||||
|
root.setAttribute("data-kanban-command-ready", "");
|
||||||
|
} catch (error) {
|
||||||
|
report(root, "restore", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((error) => {
|
||||||
|
const root = document.querySelector(ROOT);
|
||||||
|
if (root && !root.hasAttribute("data-kanban-command-error")) report(root, "open", error);
|
||||||
|
console.error("kanban durable command log failed", error);
|
||||||
|
});
|
||||||
+173
-6
@@ -22,7 +22,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
let (package, bootstrap, rendered) = build_browser_artifact(&workspace);
|
let (package, bootstrap, rendered) = build_browser_artifact(&workspace);
|
||||||
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||||
let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local");
|
let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local", None);
|
||||||
|
|
||||||
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}");
|
||||||
@@ -191,6 +191,148 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
|||||||
result.and(quit)
|
result.and(quit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()>
|
||||||
|
{
|
||||||
|
// test req: local/001 req: local/002 req: local/003 req: local/004
|
||||||
|
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("workspace root")
|
||||||
|
.to_owned();
|
||||||
|
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
|
||||||
|
let runtime = workspace.join("hemx-js/runtime/hemx.js");
|
||||||
|
let server = StaticServer::start(
|
||||||
|
package,
|
||||||
|
runtime,
|
||||||
|
bootstrap,
|
||||||
|
rendered,
|
||||||
|
"kanban_client",
|
||||||
|
Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = ProcessGuard::start(webdriver, &webdriver_addr);
|
||||||
|
let mut caps = DesiredCapabilities::firefox();
|
||||||
|
caps.set_headless()?;
|
||||||
|
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||||
|
let result = async {
|
||||||
|
driver.goto(&server.url()).await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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;
|
||||||
|
"#,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let persisted = driver
|
||||||
|
.execute(
|
||||||
|
"return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(persisted["persisted"]["order"], "1|2");
|
||||||
|
assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 1);
|
||||||
|
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");
|
||||||
|
|
||||||
|
driver.refresh().await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')",
|
||||||
|
)
|
||||||
|
.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 }",
|
||||||
|
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 click");
|
||||||
|
|
||||||
|
driver
|
||||||
|
.execute(
|
||||||
|
r#"
|
||||||
|
window.__futureCommandStored = false;
|
||||||
|
const request = indexedDB.open('hemx-kanban-v1', 1);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const tx = request.result.transaction('commands', 'readwrite');
|
||||||
|
tx.objectStore('commands').add({
|
||||||
|
id: 'future:2', schemaVersion: 2, actor: 'future', session: 'future',
|
||||||
|
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||||
|
});
|
||||||
|
tx.oncomplete = () => { window.__futureCommandStored = true; };
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
"#,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_until(&driver, "return window.__futureCommandStored === true").await?;
|
||||||
|
driver.refresh().await?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')",
|
||||||
|
)
|
||||||
|
.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);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let quit = driver.quit().await;
|
||||||
|
result.and(quit)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()>
|
async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()>
|
||||||
{
|
{
|
||||||
@@ -202,7 +344,14 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
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 server = StaticServer::start(package, runtime, bootstrap, rendered, "kanban_client");
|
let server = StaticServer::start(
|
||||||
|
package,
|
||||||
|
runtime,
|
||||||
|
bootstrap,
|
||||||
|
rendered,
|
||||||
|
"kanban_client",
|
||||||
|
Some(workspace.join("examples/kanban/static/command-log.js")),
|
||||||
|
);
|
||||||
|
|
||||||
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}");
|
||||||
@@ -455,6 +604,7 @@ impl StaticServer {
|
|||||||
bootstrap: PathBuf,
|
bootstrap: PathBuf,
|
||||||
rendered: String,
|
rendered: String,
|
||||||
asset_stem: &'static str,
|
asset_stem: &'static str,
|
||||||
|
app_module: Option<PathBuf>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
|
||||||
listener.set_nonblocking(true).expect("nonblocking fixture");
|
listener.set_nonblocking(true).expect("nonblocking fixture");
|
||||||
@@ -465,7 +615,13 @@ impl StaticServer {
|
|||||||
while !thread_stop.load(Ordering::Relaxed) {
|
while !thread_stop.load(Ordering::Relaxed) {
|
||||||
match listener.accept() {
|
match listener.accept() {
|
||||||
Ok((stream, _)) => serve(
|
Ok((stream, _)) => serve(
|
||||||
stream, &package, &runtime, &bootstrap, &rendered, asset_stem,
|
stream,
|
||||||
|
&package,
|
||||||
|
&runtime,
|
||||||
|
&bootstrap,
|
||||||
|
&rendered,
|
||||||
|
asset_stem,
|
||||||
|
app_module.as_deref(),
|
||||||
),
|
),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
thread::sleep(Duration::from_millis(10))
|
thread::sleep(Duration::from_millis(10))
|
||||||
@@ -495,6 +651,7 @@ fn serve(
|
|||||||
bootstrap: &Path,
|
bootstrap: &Path,
|
||||||
rendered: &str,
|
rendered: &str,
|
||||||
asset_stem: &str,
|
asset_stem: &str,
|
||||||
|
app_module: Option<&Path>,
|
||||||
) {
|
) {
|
||||||
let mut request = [0_u8; 2048];
|
let mut request = [0_u8; 2048];
|
||||||
let length = stream.read(&mut request).unwrap_or(0);
|
let length = stream.read(&mut request).unwrap_or(0);
|
||||||
@@ -503,7 +660,7 @@ fn serve(
|
|||||||
let (content_type, body) = match path {
|
let (content_type, body) = match path {
|
||||||
"/" => (
|
"/" => (
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
fixture_html(rendered).into_bytes(),
|
fixture_html(rendered, app_module.is_some()).into_bytes(),
|
||||||
),
|
),
|
||||||
"/hemx.js" => (
|
"/hemx.js" => (
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
@@ -521,11 +678,16 @@ fn serve(
|
|||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
fs::read(bootstrap).expect("read generated client bootstrap"),
|
fs::read(bootstrap).expect("read generated client bootstrap"),
|
||||||
),
|
),
|
||||||
|
"/app.js" if app_module.is_some() => (
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
fs::read(app_module.expect("checked app module")).expect("read app module"),
|
||||||
|
),
|
||||||
_ => ("text/plain", b"not found".to_vec()),
|
_ => ("text/plain", b"not found".to_vec()),
|
||||||
};
|
};
|
||||||
let status = if path == "/"
|
let status = if path == "/"
|
||||||
|| path == "/hemx.js"
|
|| path == "/hemx.js"
|
||||||
|| path == "/hemx.client.js"
|
|| path == "/hemx.client.js"
|
||||||
|
|| (path == "/app.js" && app_module.is_some())
|
||||||
|| path.starts_with(&format!("/{asset_stem}"))
|
|| path.starts_with(&format!("/{asset_stem}"))
|
||||||
{
|
{
|
||||||
"200 OK"
|
"200 OK"
|
||||||
@@ -536,9 +698,14 @@ fn serve(
|
|||||||
stream.write_all(&body).expect("write fixture body");
|
stream.write_all(&body).expect("write fixture body");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fixture_html(rendered: &str) -> String {
|
fn fixture_html(rendered: &str, has_app_module: bool) -> String {
|
||||||
|
let app_module = if has_app_module {
|
||||||
|
"<script type=\"module\" src=\"/app.js\"></script>"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script></body></html>"
|
"<!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