fix(operations): bound handler lifetimes
req: operations/003
This commit is contained in:
Generated
+1
@@ -585,6 +585,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thirtyfour 0.35.0",
|
"thirtyfour 0.35.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ hemx-axum = { path = "../../hemx-axum", optional = true }
|
|||||||
hemx-sync = { path = "../../hemx-sync" }
|
hemx-sync = { path = "../../hemx-sync" }
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"], optional = true }
|
tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "time"], optional = true }
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
hemplate = { path = "../../../hemplate/hemplate" }
|
hemplate = { path = "../../../hemplate/hemplate" }
|
||||||
@@ -41,6 +41,7 @@ hemplate = { path = "../../../hemplate/hemplate" }
|
|||||||
hemx-test = { path = "../../hemx-test" }
|
hemx-test = { path = "../../hemx-test" }
|
||||||
scraper = "0.23"
|
scraper = "0.23"
|
||||||
thirtyfour = "0.35"
|
thirtyfour = "0.35"
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
hemx-build = { path = "../../hemx-build" }
|
hemx-build = { path = "../../hemx-build" }
|
||||||
|
|||||||
+129
-14
@@ -1,7 +1,8 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, Request, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::{IntoResponse, 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};
|
||||||
@@ -18,6 +19,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::future::Future;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -26,6 +28,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")];
|
const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")];
|
||||||
const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT: usize = 64;
|
const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT: usize = 64;
|
||||||
|
const ORDINARY_HANDLER_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const STARTUP_REPLAY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const MAX_SYNC_STORE_BYTES: usize = 1024 * 1024;
|
||||||
const ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
const ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
const ACKNOWLEDGEMENT_RECONNECT_BACKOFF: [Duration; 3] = [
|
const ACKNOWLEDGEMENT_RECONNECT_BACKOFF: [Duration; 3] = [
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
@@ -228,15 +233,26 @@ struct PersistedAcknowledgement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SyncStore {
|
impl SyncStore {
|
||||||
fn load(&self) -> Result<SyncState, String> {
|
async fn load(&self) -> Result<SyncState, String> {
|
||||||
if !self.0.exists() {
|
if !self.0.exists() {
|
||||||
return Ok(SyncState {
|
return Ok(SyncState {
|
||||||
next_sequence: 1,
|
next_sequence: 1,
|
||||||
..SyncState::default()
|
..SyncState::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let bytes =
|
let bytes = run_with_timeout(
|
||||||
fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?;
|
STARTUP_REPLAY_TIMEOUT,
|
||||||
|
"startup sync-store read/replay",
|
||||||
|
tokio::fs::read(&self.0),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.map_err(|error| format!("read {}: {error}", self.0.display()))?;
|
||||||
|
if bytes.len() > MAX_SYNC_STORE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"sync store {} exceeds {MAX_SYNC_STORE_BYTES} bytes",
|
||||||
|
self.0.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
let persisted: PersistedSync = serde_json::from_slice(&bytes)
|
let persisted: PersistedSync = serde_json::from_slice(&bytes)
|
||||||
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
|
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
|
||||||
if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 {
|
if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 {
|
||||||
@@ -408,20 +424,46 @@ struct Presence {
|
|||||||
count: u64,
|
count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_with_timeout<F, T>(
|
||||||
|
duration: Duration,
|
||||||
|
operation: &'static str,
|
||||||
|
future: F,
|
||||||
|
) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
{
|
||||||
|
tokio::time::timeout(duration, future)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{operation} timed out after {} ms", duration.as_millis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bounded_handler(duration: Duration, request: Request, next: Next) -> Response {
|
||||||
|
match run_with_timeout(duration, "ordinary request", next.run(request)).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(message) => (StatusCode::GATEWAY_TIMEOUT, message).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ordinary_handler_timeout(request: Request, next: Next) -> Response {
|
||||||
|
bounded_handler(ORDINARY_HANDLER_TIMEOUT, request, next).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE")
|
let sync_store = std::env::var_os("HEMX_KANBAN_SYNC_STORE")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.map(SyncStore);
|
.map(SyncStore);
|
||||||
let mut sync = sync_store
|
let mut sync = if let Some(store) = sync_store.as_ref() {
|
||||||
.as_ref()
|
store
|
||||||
.map(SyncStore::load)
|
.load()
|
||||||
.transpose()
|
.await
|
||||||
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
|
.unwrap_or_else(|error| panic!("cannot start with sync store: {error}"))
|
||||||
.unwrap_or_else(|| SyncState {
|
} else {
|
||||||
|
SyncState {
|
||||||
next_sequence: 1,
|
next_sequence: 1,
|
||||||
..SyncState::default()
|
..SyncState::default()
|
||||||
});
|
}
|
||||||
|
};
|
||||||
sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER")
|
sync.retained_after = std::env::var("HEMX_KANBAN_RETAINED_AFTER")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
@@ -453,16 +495,18 @@ async fn main() {
|
|||||||
.unwrap_or(ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL),
|
.unwrap_or(ACKNOWLEDGEMENT_HEARTBEAT_INTERVAL),
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let ordinary_routes = Router::new()
|
||||||
.route("/", get(home).post(interact))
|
.route("/", get(home).post(interact))
|
||||||
.route("/events", get(events))
|
.route("/events", get(events))
|
||||||
.route("/sync-demo", get(sync_demo))
|
.route("/sync-demo", get(sync_demo))
|
||||||
.route("/sync.js", get(sync_js))
|
.route("/sync.js", get(sync_js))
|
||||||
.route("/sync/context", get(sync_context))
|
.route("/sync/context", get(sync_context))
|
||||||
.route("/sync/commands", post(sync_command))
|
.route("/sync/commands", post(sync_command))
|
||||||
.route("/sync/acknowledgements", get(sync_acknowledgements))
|
|
||||||
.route("/sync/snapshot", get(sync_snapshot))
|
.route("/sync/snapshot", get(sync_snapshot))
|
||||||
.route(runtime_js_path(), get(runtime))
|
.route(runtime_js_path(), get(runtime))
|
||||||
|
.layer(middleware::from_fn(ordinary_handler_timeout));
|
||||||
|
let app = ordinary_routes
|
||||||
|
.merge(Router::new().route("/sync/acknowledgements", get(sync_acknowledgements)))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = std::env::var("HEMX_KANBAN_ADDR")
|
let addr = std::env::var("HEMX_KANBAN_ADDR")
|
||||||
@@ -1067,6 +1111,77 @@ mod tests {
|
|||||||
select_options_selector, small_text_selector, strong_text_selector,
|
select_options_selector, small_text_selector, strong_text_selector,
|
||||||
};
|
};
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct CancelProof(Arc<AtomicBool>);
|
||||||
|
|
||||||
|
impl Drop for CancelProof {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: operations/003
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_handlers_timeout_and_cancel_inflight_work() {
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let proof = Arc::clone(&cancelled);
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/slow",
|
||||||
|
get(move || {
|
||||||
|
let proof = Arc::clone(&proof);
|
||||||
|
async move {
|
||||||
|
let _cancel_proof = CancelProof(proof);
|
||||||
|
std::future::pending::<Response>().await
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.layer(middleware::from_fn(|request, next| async move {
|
||||||
|
bounded_handler(Duration::from_millis(20), request, next).await
|
||||||
|
}));
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::get("/slow")
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
assert!(cancelled.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: operations/003
|
||||||
|
#[tokio::test]
|
||||||
|
async fn startup_replay_timeout_cancels_inflight_work_with_a_named_error() {
|
||||||
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
|
let proof = Arc::clone(&cancelled);
|
||||||
|
let error = run_with_timeout(Duration::from_millis(20), "startup replay", async move {
|
||||||
|
let _cancel_proof = CancelProof(proof);
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("startup replay must time out");
|
||||||
|
assert_eq!(error, "startup replay timed out after 20 ms");
|
||||||
|
assert!(cancelled.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn startup_replay_rejects_oversized_store_before_decoding() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"hemx-kanban-oversized-store-{}.json",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::write(&path, vec![b' '; MAX_SYNC_STORE_BYTES + 1]).unwrap();
|
||||||
|
let error = match SyncStore(path.clone()).load().await {
|
||||||
|
Ok(_) => panic!("oversized store must be rejected"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
assert!(error.contains("exceeds 1048576 bytes"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
|
|||||||
@@ -2196,6 +2196,83 @@ async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> We
|
|||||||
result.and(quit)
|
result.and(quit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_browser_request_exposes_deadline_and_cancels_on_pagehide() -> WebDriverResult<()>
|
||||||
|
{
|
||||||
|
// test req: operations/003
|
||||||
|
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);
|
||||||
|
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
|
||||||
|
.expect("start 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?;
|
||||||
|
wait_until(
|
||||||
|
&driver,
|
||||||
|
"return document.querySelector('[data-hemx-root]')?.getAttribute('data-hemx-request-timeout-ms') === '10000'",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let proof = driver
|
||||||
|
.execute_async(
|
||||||
|
r#"
|
||||||
|
const done = arguments[arguments.length - 1];
|
||||||
|
(async () => {
|
||||||
|
const root = document.querySelector('[data-hemx-root]');
|
||||||
|
const form = root.querySelector('form');
|
||||||
|
form.querySelector('[name=title]').value = 'cancel me';
|
||||||
|
let started = false;
|
||||||
|
let abortDetail;
|
||||||
|
window.fetch = (_url, init) => new Promise((_resolve, reject) => {
|
||||||
|
started = true;
|
||||||
|
init.signal.addEventListener('abort', () => {
|
||||||
|
abortDetail = { name: init.signal.reason.name, message: init.signal.reason.message };
|
||||||
|
reject(init.signal.reason);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
while (!started) await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
window.dispatchEvent(new PageTransitionEvent('pagehide'));
|
||||||
|
while (!abortDetail || form.hasAttribute('data-hemx-pending')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
done({
|
||||||
|
abortDetail,
|
||||||
|
timeoutMs: root.getAttribute('data-hemx-request-timeout-ms'),
|
||||||
|
pending: form.hasAttribute('data-hemx-pending'),
|
||||||
|
});
|
||||||
|
})().catch((error) => done({ error: String(error), stack: error?.stack }));
|
||||||
|
"#,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.clone();
|
||||||
|
assert!(proof["error"].is_null(), "ordinary cancellation failed: {proof}");
|
||||||
|
assert_eq!(proof["timeoutMs"], "10000");
|
||||||
|
assert_eq!(proof["abortDetail"]["name"], "AbortError");
|
||||||
|
assert!(proof["abortDetail"]["message"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|message| message.contains("cancelled because page is hidden")));
|
||||||
|
assert_eq!(proof["pending"], false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let quit = driver.quit().await;
|
||||||
|
result.and(quit)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation(
|
async fn acknowledgement_stream_bounds_reconnect_buffering_heartbeat_and_cancellation(
|
||||||
) -> WebDriverResult<()> {
|
) -> WebDriverResult<()> {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@
|
|||||||
let currentOperationId = null;
|
let currentOperationId = null;
|
||||||
const clientHandlers = new Map();
|
const clientHandlers = new Map();
|
||||||
const clientRuns = new WeakMap();
|
const clientRuns = new WeakMap();
|
||||||
|
const activeRequests = new Set();
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
for (const controller of activeRequests) {
|
||||||
|
controller.abort(new DOMException("hemx request cancelled because page is hidden", "AbortError"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function roots() {
|
function roots() {
|
||||||
const found = [];
|
const found = [];
|
||||||
@@ -315,6 +323,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => abort.abort(new DOMException(`hemx request timed out after ${REQUEST_TIMEOUT_MS} ms`, "TimeoutError")),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
activeRequests.add(abort);
|
||||||
let finish;
|
let finish;
|
||||||
const done = new Promise((resolve) => { finish = resolve; });
|
const done = new Promise((resolve) => { finish = resolve; });
|
||||||
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
|
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
|
||||||
@@ -346,6 +359,8 @@
|
|||||||
emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null });
|
emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
activeRequests.delete(abort);
|
||||||
if (pending.get(target)?.abort === abort) {
|
if (pending.get(target)?.abort === abort) {
|
||||||
pending.delete(target);
|
pending.delete(target);
|
||||||
showPending(target, false);
|
showPending(target, false);
|
||||||
@@ -1080,6 +1095,7 @@
|
|||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
roots().forEach((root) => {
|
roots().forEach((root) => {
|
||||||
|
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
|
||||||
try {
|
try {
|
||||||
bootstrapState(root);
|
bootstrapState(root);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -191,6 +191,20 @@ fn runtime_confirms_before_handler_dispatch() {
|
|||||||
assert!(source.contains("return;"));
|
assert!(source.contains("return;"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_bounds_and_cancels_ordinary_requests() {
|
||||||
|
// test req: operations/003
|
||||||
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const REQUEST_TIMEOUT_MS = 10_000"));
|
||||||
|
assert!(source.contains("hemx request timed out after ${REQUEST_TIMEOUT_MS} ms"));
|
||||||
|
assert!(source.contains("window.addEventListener(\"pagehide\""));
|
||||||
|
assert!(source.contains("hemx request cancelled because page is hidden"));
|
||||||
|
assert!(source.contains("activeRequests.add(abort)"));
|
||||||
|
assert!(source.contains("activeRequests.delete(abort)"));
|
||||||
|
assert!(source.contains("data-hemx-request-timeout-ms"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_fetches_with_same_origin_credentials() {
|
fn runtime_fetches_with_same_origin_credentials() {
|
||||||
// req: auth/005
|
// req: auth/005
|
||||||
|
|||||||
Reference in New Issue
Block a user