diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
index f2f8d51..ead5953 100644
--- a/.github/workflows/bench.yml
+++ b/.github/workflows/bench.yml
@@ -30,18 +30,19 @@ jobs:
run: |
set -euo pipefail
mkdir -p bench-output
- for crate in \
- ras-jsonrpc-macro \
- ras-rest-macro \
- ras-file-macro \
- ras-jsonrpc-bidirectional-macro
+ while read -r crate bench
do
- echo "::group::$crate"
- cargo bench -p "$crate" -- \
+ echo "::group::$crate/$bench"
+ cargo bench -p "$crate" --bench "$bench" -- \
--warm-up-time 1 --measurement-time 3 \
- | tee "bench-output/$crate.txt"
+ | tee "bench-output/$crate-$bench.txt"
echo "::endgroup::"
- done
+ done <<'BENCHES'
+ ras-jsonrpc-macro dispatch
+ ras-rest-macro dispatch
+ ras-file-macro streaming
+ ras-jsonrpc-bidirectional-macro roundtrip
+ BENCHES
- name: Upload bench artifacts
if: always()
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 10ae2fe..1db7e0a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,56 @@ jobs:
- name: Run tests
run: cargo test --workspace --all-features -- --nocapture --test-threads=4
+ playwright:
+ name: Playwright E2E
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Cargo
+ uses: Swatinem/rust-cache@v2
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: tests/playwright/package-lock.json
+
+ - name: Install Playwright package
+ working-directory: tests/playwright
+ run: npm ci
+
+ - name: Install Playwright browsers
+ working-directory: tests/playwright
+ run: npx playwright install --with-deps chromium
+
+ - name: Run Playwright tests
+ working-directory: tests/playwright
+ run: npm test
+
+ - name: Upload Playwright report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: tests/playwright/playwright-report
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Upload Playwright test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-test-results
+ path: tests/playwright/test-results
+ if-no-files-found: ignore
+ retention-days: 7
+
coverage:
name: Coverage report
runs-on: ubuntu-latest
diff --git a/Cargo.lock b/Cargo.lock
index 03834dc..bd7eaef 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2947,6 +2947,42 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "playwright-jsonrpc-fixture"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "ras-auth-core",
+ "ras-jsonrpc-core",
+ "ras-jsonrpc-macro",
+ "ras-jsonrpc-types",
+ "reqwest",
+ "schemars",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "playwright-rest-fixture"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "axum",
+ "axum-extra",
+ "ras-auth-core",
+ "ras-rest-core",
+ "ras-rest-macro",
+ "reqwest",
+ "schemars",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "plotters"
version = "0.3.7"
diff --git a/Cargo.toml b/Cargo.toml
index a4bf3cd..89b5a72 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ members = [
"examples/rest-wasm-example/rest-api",
"examples/rest-wasm-example/rest-backend",
"examples/wasm-ui-demo",
+ "tests/playwright/fixtures/*",
]
resolver = "3"
diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/rest/ras-rest-macro/src/api_explorer_template.html
new file mode 100644
index 0000000..6b19b82
--- /dev/null
+++ b/crates/rest/ras-rest-macro/src/api_explorer_template.html
@@ -0,0 +1,1167 @@
+
+
+
+
+
+ API Explorer
+
+
+
+
+
+
+
+
+
+
Select an operation
+
Choose an operation to prepare a request.
+
+
+
+
+
+
+
+
+
+ Request
+
+
+
+
No operation selected.
+
+
+
+
+ Saved requests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs
index 5d3306f..119cbb2 100644
--- a/crates/rest/ras-rest-macro/src/static_hosting.rs
+++ b/crates/rest/ras-rest-macro/src/static_hosting.rs
@@ -1,20 +1,17 @@
-//! Static file hosting module for REST services
-//!
-//! This module provides functionality to serve static files, particularly for API documentation
-//! and OpenAPI spec hosting.
+//! Static API explorer hosting for REST services
use crate::ServiceDefinition;
use proc_macro2::TokenStream;
use quote::quote;
-/// Configuration for static file hosting
+/// Configuration for static file hosting.
#[derive(Debug, Clone)]
pub struct StaticHostingConfig {
- /// Whether to enable static hosting
+ /// Whether to enable static hosting.
pub serve_docs: bool,
- /// URL path for documentation (default "/docs")
+ /// URL path for documentation (default "/docs").
pub docs_path: String,
- /// UI theme selection (default "default")
+ /// UI theme selection retained for macro compatibility.
pub ui_theme: String,
}
@@ -28,7 +25,7 @@ impl Default for StaticHostingConfig {
}
}
-/// Generates static file serving routes code
+/// Generates static API explorer handler code.
pub fn generate_static_hosting_code(
service_def: &ServiceDefinition,
static_config: &StaticHostingConfig,
@@ -37,1167 +34,57 @@ pub fn generate_static_hosting_code(
return quote! {};
}
+ const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html");
+
let service_name = &service_def.service_name;
- let base_path = &service_def.base_path;
- let docs_path = &static_config.docs_path;
- let ui_theme = &static_config.ui_theme;
+ let base_path = service_def.base_path.trim_end_matches('/').to_string();
+ let docs_path = ensure_leading_slash(&static_config.docs_path);
+ let openapi_route = format!("{}/openapi.json", docs_path.trim_end_matches('/'));
+ let spec_path = join_paths(&base_path, &openapi_route);
+ let api_base_path = if base_path.is_empty() {
+ "/".to_string()
+ } else {
+ base_path
+ };
let openapi_fn_name = quote::format_ident!(
"generate_{}_openapi",
service_name.to_string().to_lowercase()
);
-
let docs_handler_name =
quote::format_ident!("{}_docs_handler", service_name.to_string().to_lowercase());
+ let template_lit = syn::LitStr::new(TEMPLATE_CONTENT, proc_macro2::Span::call_site());
quote! {
#[cfg(feature = "server")]
- // Handler for serving the documentation index
async fn #docs_handler_name() -> ::axum::response::Html {
- let openapi_spec = #openapi_fn_name();
- let spec_json = ::serde_json::to_string_pretty(&openapi_spec)
- .unwrap_or_else(|_| "{}".to_string());
-
- let html_content = generate_docs_html(&spec_json, #ui_theme, #base_path, #docs_path);
- ::axum::response::Html(html_content)
+ static HTML: ::std::sync::OnceLock = ::std::sync::OnceLock::new();
+
+ let html = HTML.get_or_init(|| {
+ const TEMPLATE: &str = #template_lit;
+ let config_json = ::serde_json::json!({
+ "serviceName": stringify!(#service_name),
+ "protocol": "rest",
+ "specPath": #spec_path,
+ "apiBasePath": #api_base_path
+ })
+ .to_string()
+ .replace("<", "\\u003c");
+
+ TEMPLATE.replace("{EXPLORER_CONFIG_JSON}", &config_json)
+ });
+
+ ::axum::response::Html(html.clone())
}
#[cfg(feature = "server")]
- // Generate HTML content for the API explorer page
- fn generate_docs_html(openapi_spec: &str, theme: &str, base_path: &str, docs_path: &str) -> String {
- format!(
- r#"
-
-
-
-
- {} - REST API Explorer
-
-
-
-
-
-
-
-
-
-
-"#,
- stringify!(#service_name),
- stringify!(#service_name),
- "./docs/openapi.json",
- #base_path
- )
- }
-
- #[cfg(feature = "server")]
- // Generate OpenAPI JSON endpoint handler
async fn openapi_json_handler() -> ::axum::Json<::serde_json::Value> {
::axum::Json(#openapi_fn_name())
}
}
}
-/// Generates route registrations for static hosting
+/// Generates route registrations for static hosting.
pub fn generate_static_routes(
service_def: &ServiceDefinition,
static_config: &StaticHostingConfig,
@@ -1206,8 +93,8 @@ pub fn generate_static_routes(
return quote! {};
}
- let docs_path = &static_config.docs_path;
- let openapi_path = format!("{}/openapi.json", docs_path);
+ let docs_path = ensure_leading_slash(&static_config.docs_path);
+ let openapi_path = format!("{}/openapi.json", docs_path.trim_end_matches('/'));
let docs_handler_name = quote::format_ident!(
"{}_docs_handler",
service_def.service_name.to_string().to_lowercase()
@@ -1216,10 +103,28 @@ pub fn generate_static_routes(
quote! {
#[cfg(feature = "server")]
{
- // Register static hosting routes
router = router
.route(#docs_path, ::axum::routing::get(#docs_handler_name))
.route(#openapi_path, ::axum::routing::get(openapi_json_handler));
}
}
}
+
+fn ensure_leading_slash(path: &str) -> String {
+ if path.starts_with('/') {
+ path.to_string()
+ } else {
+ format!("/{path}")
+ }
+}
+
+fn join_paths(base: &str, path: &str) -> String {
+ let base = base.trim_end_matches('/');
+ let path = ensure_leading_slash(path);
+
+ if base.is_empty() {
+ path
+ } else {
+ format!("{base}{path}")
+ }
+}
diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs
index c2b53fb..64f10eb 100644
--- a/crates/rest/ras-rest-macro/tests/http_integration.rs
+++ b/crates/rest/ras-rest-macro/tests/http_integration.rs
@@ -468,6 +468,32 @@ async fn make_rest_request(
request_builder.send().await
}
+#[tokio::test]
+async fn test_docs_explorer_routes_generated() {
+ let (base_url, _handle) = create_rest_test_server().await;
+
+ let docs_response = reqwest::get(format!("{}/api/v1/docs", base_url))
+ .await
+ .unwrap();
+ assert_eq!(docs_response.status(), 200);
+
+ let docs = docs_response.text().await.unwrap();
+ assert!(docs.contains("\"TestRestService\""));
+ assert!(docs.contains("\"rest\""));
+ assert!(docs.contains("/api/v1/docs/openapi.json"));
+ assert!(docs.contains("id=\"jwt-token\""));
+ assert!(docs.contains("id=\"saved-list\""));
+
+ let spec_response = reqwest::get(format!("{}/api/v1/docs/openapi.json", base_url))
+ .await
+ .unwrap();
+ assert_eq!(spec_response.status(), 200);
+
+ let spec: serde_json::Value = spec_response.json().await.unwrap();
+ assert_eq!(spec["info"]["title"], "TestRestService REST API");
+ assert!(spec["paths"].is_object());
+}
+
#[tokio::test]
async fn test_unauthorized_endpoints() {
let (base_url, _handle) = create_rest_test_server().await;
diff --git a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs
index c1b1784..7ffdb1e 100644
--- a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs
+++ b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs
@@ -19,11 +19,13 @@ fn test_xss_protection_in_generated_html() {
#[test]
fn test_generated_docs_do_not_store_jwt_in_local_storage() {
- let source = include_str!("../src/static_hosting.rs");
- assert!(!source.contains("localStorage.getItem('jwt-token')"));
- assert!(!source.contains("localStorage.setItem('jwt-token'"));
- assert!(!source.contains("localStorage.removeItem('jwt-token'"));
- assert!(source.contains("sessionStorage.setItem('jwt-token'"));
+ let template = include_str!("../src/api_explorer_template.html");
+ assert!(!template.contains("localStorage.getItem('jwt-token')"));
+ assert!(!template.contains("localStorage.setItem('jwt-token'"));
+ assert!(!template.contains("localStorage.removeItem('jwt-token'"));
+ assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`"));
+ assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`"));
+ assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\""));
}
fn escape_html(unsafe_str: &str) -> String {
diff --git a/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html b/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html
deleted file mode 100644
index 9d0eb48..0000000
--- a/crates/rpc/ras-jsonrpc-macro/src/jsonrpc_explorer_template.html
+++ /dev/null
@@ -1,1212 +0,0 @@
-
-
-
-
-
- {SERVICE_NAME} - JSON-RPC API Explorer
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs
index 60b6b9d..5dc0807 100644
--- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs
+++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs
@@ -29,12 +29,10 @@ pub fn generate_static_hosting_code(
return TokenStream::new();
}
- // Load the template content at macro compile time
- const TEMPLATE_CONTENT: &str = include_str!("jsonrpc_explorer_template.html");
+ const TEMPLATE_CONTENT: &str =
+ include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html");
let explorer_path_suffix = &config.explorer_path;
- // Use path relative to explorer directory
- let openrpc_path_js = "explorer/openrpc.json".to_string();
let service_name_str = service_name.to_string();
let service_name_lower = service_name_str.to_lowercase();
let openrpc_fn_name_str = ["generate_", &service_name_lower, "_openrpc"].concat();
@@ -53,19 +51,27 @@ pub fn generate_static_hosting_code(
let explorer_path = format!("{}{}", base_path, #explorer_path_suffix);
let openrpc_path = format!("{}/openrpc.json", &explorer_path);
- let serve_explorer = {
- let base_path = base_path.to_string();
- move || async move {
- // Template is embedded at macro expansion time
- const TEMPLATE: &str = #template_lit;
+ let explorer_html = {
+ const TEMPLATE: &str = #template_lit;
+ let config_json = ::serde_json::json!({
+ "serviceName": #service_name_str,
+ "protocol": "jsonrpc",
+ "specPath": &openrpc_path,
+ "apiBasePath": base_path
+ })
+ .to_string()
+ .replace("<", "\\u003c");
- // Replace placeholders
- let html = TEMPLATE
- .replace("{SERVICE_NAME}", #service_name_str)
- .replace("{OPENRPC_PATH}", openrpc_path_js)
- .replace("{RPC_BASE_PATH}", &base_path);
+ ::std::sync::Arc::new(TEMPLATE.replace("{EXPLORER_CONFIG_JSON}", &config_json))
+ };
- Html(html)
+ let serve_explorer = {
+ let explorer_html = explorer_html.clone();
+ move || {
+ let explorer_html = explorer_html.clone();
+ async move {
+ Html((*explorer_html).clone())
+ }
}
};
diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs
index 28f04a4..31ebd6d 100644
--- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs
+++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs
@@ -61,8 +61,11 @@ mod tests {
assert_eq!(response.status(), 200);
let content = response.text().await.unwrap();
- assert!(content.contains("UserService JSON-RPC Explorer"));
- assert!(content.contains("Authentication"));
+ assert!(content.contains("\"UserService\""));
+ assert!(content.contains("id=\"jwt-token\""));
+ assert!(content.contains("id=\"saved-list\""));
+ assert!(content.contains("id=\"history-list\""));
+ assert!(content.contains("\"jsonrpc\""));
// Test that the OpenRPC document is accessible
let response = reqwest::get(format!("http://{}/explorer/openrpc.json", addr))
diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs
index cd5704a..6d51852 100644
--- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs
+++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs
@@ -1,8 +1,10 @@
#[test]
fn test_generated_explorer_does_not_store_jwt_in_local_storage() {
- let template = include_str!("../src/jsonrpc_explorer_template.html");
+ let template = include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html");
assert!(!template.contains("localStorage.getItem('jwt-token')"));
assert!(!template.contains("localStorage.setItem('jwt-token'"));
assert!(!template.contains("localStorage.removeItem('jwt-token'"));
- assert!(template.contains("sessionStorage.setItem('jwt-token'"));
+ assert!(!template.contains("localStorage.setItem(`${storagePrefix}:bearer-token`"));
+ assert!(template.contains("sessionStorage.setItem(`${storagePrefix}:${key}`"));
+ assert!(template.contains("localStorage.setItem(\"ras-explorer-theme\""));
}
diff --git a/tests/playwright/README.md b/tests/playwright/README.md
new file mode 100644
index 0000000..5510af7
--- /dev/null
+++ b/tests/playwright/README.md
@@ -0,0 +1,34 @@
+# API Explorer Playwright Tests
+
+These tests exercise the generated REST and JSON-RPC API explorers in a real
+browser. Dedicated Rust fixture servers are started by Playwright.
+
+## Local setup
+
+```bash
+npm --prefix tests/playwright install
+npm --prefix tests/playwright run install:browsers
+npm --prefix tests/playwright test
+```
+
+For headed debugging:
+
+```bash
+npm --prefix tests/playwright run test:headed
+```
+
+The fixtures use:
+
+- REST: `http://127.0.0.1:3101/api/v1/docs`
+- JSON-RPC: `http://127.0.0.1:3102/rpc/explorer`
+
+To avoid local port collisions, override the ports before running the suite:
+
+```bash
+PLAYWRIGHT_REST_PORT=3201 PLAYWRIGHT_JSONRPC_PORT=3202 npm --prefix tests/playwright test
+```
+
+Test tokens:
+
+- `user-token`
+- `admin-token`
diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml
new file mode 100644
index 0000000..84ce56e
--- /dev/null
+++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "playwright-jsonrpc-fixture"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[features]
+default = ["server"]
+server = ["ras-jsonrpc-macro/server"]
+client = ["ras-jsonrpc-macro/client"]
+
+[dependencies]
+anyhow = { workspace = true }
+axum = { workspace = true }
+ras-auth-core = { path = "../../../../crates/core/ras-auth-core" }
+ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core" }
+ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro" }
+ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types" }
+reqwest = { workspace = true }
+schemars = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
diff --git a/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs
new file mode 100644
index 0000000..a690edb
--- /dev/null
+++ b/tests/playwright/fixtures/jsonrpc-fixture/src/main.rs
@@ -0,0 +1,107 @@
+use std::collections::HashSet;
+
+use anyhow::Result;
+use axum::Router;
+use ras_jsonrpc_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser};
+use ras_jsonrpc_macro::jsonrpc_service;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct PingRequest {
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct PingResponse {
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct CreateWidgetRequest {
+ pub name: String,
+ pub owner: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct Widget {
+ pub id: String,
+ pub name: String,
+ pub owner: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct ProfileResponse {
+ pub user_id: String,
+ pub permissions: Vec,
+}
+
+jsonrpc_service!({
+ service_name: ExplorerRpcFixture,
+ openrpc: true,
+ explorer: true,
+ methods: [
+ UNAUTHORIZED ping(PingRequest) -> PingResponse,
+ UNAUTHORIZED no_params(()) -> String,
+ WITH_PERMISSIONS(["admin"]) create_widget(CreateWidgetRequest) -> Widget,
+ WITH_PERMISSIONS(["user"]) current_profile(()) -> ProfileResponse,
+ ]
+});
+
+struct FixtureAuthProvider;
+
+impl AuthProvider for FixtureAuthProvider {
+ fn authenticate(&self, token: String) -> AuthFuture<'_> {
+ Box::pin(async move {
+ let (user_id, permissions) = match token.as_str() {
+ "user-token" => ("user-1", vec!["user"]),
+ "admin-token" => ("admin-1", vec!["user", "admin"]),
+ _ => return Err(AuthError::InvalidToken),
+ };
+
+ Ok(AuthenticatedUser {
+ user_id: user_id.to_string(),
+ permissions: permissions
+ .into_iter()
+ .map(str::to_string)
+ .collect::>(),
+ metadata: None,
+ })
+ })
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let rpc_router = ExplorerRpcFixtureBuilder::new("/rpc")
+ .auth_provider(FixtureAuthProvider)
+ .ping_handler(|request| async move {
+ Ok(PingResponse {
+ message: format!("pong: {}", request.message),
+ })
+ })
+ .no_params_handler(|_request| async move { Ok("no params ok".to_string()) })
+ .create_widget_handler(|_user, request| async move {
+ Ok(Widget {
+ id: "rpc-created-widget".to_string(),
+ name: request.name,
+ owner: request.owner,
+ })
+ })
+ .current_profile_handler(|user, _request| async move {
+ Ok(ProfileResponse {
+ user_id: user.user_id.clone(),
+ permissions: user.permissions.iter().cloned().collect(),
+ })
+ })
+ .build()
+ .expect("fixture JSON-RPC service should build");
+
+ let app = Router::new().merge(rpc_router);
+ let bind_addr =
+ std::env::var("PLAYWRIGHT_JSONRPC_ADDR").unwrap_or_else(|_| "127.0.0.1:3102".to_string());
+ let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
+ axum::serve(listener, app).await?;
+
+ Ok(())
+}
diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml
new file mode 100644
index 0000000..7254c85
--- /dev/null
+++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "playwright-rest-fixture"
+version = "0.0.0"
+edition = "2024"
+publish = false
+
+[features]
+default = ["server"]
+server = ["ras-rest-macro/server"]
+client = ["ras-rest-macro/client"]
+
+[dependencies]
+anyhow = { workspace = true }
+async-trait = { workspace = true }
+axum = { workspace = true }
+axum-extra = { workspace = true }
+ras-auth-core = { path = "../../../../crates/core/ras-auth-core" }
+ras-rest-core = { path = "../../../../crates/rest/ras-rest-core" }
+ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro" }
+reqwest = { workspace = true }
+schemars = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
diff --git a/tests/playwright/fixtures/rest-fixture/src/main.rs b/tests/playwright/fixtures/rest-fixture/src/main.rs
new file mode 100644
index 0000000..b97c40d
--- /dev/null
+++ b/tests/playwright/fixtures/rest-fixture/src/main.rs
@@ -0,0 +1,148 @@
+use std::collections::HashSet;
+
+use anyhow::Result;
+use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser};
+use ras_rest_core::{RestResponse, RestResult};
+use ras_rest_macro::rest_service;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct HealthResponse {
+ pub status: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct Widget {
+ pub id: String,
+ pub name: String,
+ pub owner: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct WidgetsResponse {
+ pub widgets: Vec,
+ pub total: usize,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct CreateWidgetRequest {
+ pub name: String,
+ pub owner: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct ProfileResponse {
+ pub user_id: String,
+ pub permissions: Vec,
+}
+
+rest_service!({
+ service_name: ExplorerRestFixture,
+ base_path: "/api/v1",
+ openapi: true,
+ serve_docs: true,
+ docs_path: "/docs",
+ endpoints: [
+ GET UNAUTHORIZED health() -> HealthResponse,
+ GET UNAUTHORIZED widgets/{id: String}() -> Widget,
+ GET UNAUTHORIZED search/widgets ? q: String & limit: Option () -> WidgetsResponse,
+ POST WITH_PERMISSIONS(["admin"]) widgets(CreateWidgetRequest) -> Widget,
+ GET WITH_PERMISSIONS(["user"]) profile() -> ProfileResponse,
+ ]
+});
+
+struct FixtureAuthProvider;
+
+impl AuthProvider for FixtureAuthProvider {
+ fn authenticate(&self, token: String) -> AuthFuture<'_> {
+ Box::pin(async move {
+ let (user_id, permissions) = match token.as_str() {
+ "user-token" => ("user-1", vec!["user"]),
+ "admin-token" => ("admin-1", vec!["user", "admin"]),
+ _ => return Err(AuthError::InvalidToken),
+ };
+
+ Ok(AuthenticatedUser {
+ user_id: user_id.to_string(),
+ permissions: permissions
+ .into_iter()
+ .map(str::to_string)
+ .collect::>(),
+ metadata: None,
+ })
+ })
+ }
+}
+
+struct FixtureService;
+
+#[async_trait::async_trait]
+impl ExplorerRestFixtureTrait for FixtureService {
+ async fn get_health(&self) -> RestResult {
+ Ok(RestResponse::ok(HealthResponse {
+ status: "ok".to_string(),
+ }))
+ }
+
+ async fn get_widgets_by_id(&self, id: String) -> RestResult {
+ Ok(RestResponse::ok(Widget {
+ id,
+ name: "Fixture Widget".to_string(),
+ owner: "public".to_string(),
+ }))
+ }
+
+ async fn get_search_widgets(
+ &self,
+ q: String,
+ limit: Option,
+ ) -> RestResult {
+ let count = limit.unwrap_or(2).min(5) as usize;
+ let widgets = (0..count)
+ .map(|idx| Widget {
+ id: format!("widget-{idx}"),
+ name: format!("{q}-{idx}"),
+ owner: "search".to_string(),
+ })
+ .collect::>();
+
+ Ok(RestResponse::ok(WidgetsResponse {
+ total: widgets.len(),
+ widgets,
+ }))
+ }
+
+ async fn post_widgets(
+ &self,
+ _user: &AuthenticatedUser,
+ request: CreateWidgetRequest,
+ ) -> RestResult {
+ Ok(RestResponse::created(Widget {
+ id: "created-widget".to_string(),
+ name: request.name,
+ owner: request.owner,
+ }))
+ }
+
+ async fn get_profile(&self, user: &AuthenticatedUser) -> RestResult {
+ Ok(RestResponse::ok(ProfileResponse {
+ user_id: user.user_id.clone(),
+ permissions: user.permissions.iter().cloned().collect(),
+ }))
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let app = ExplorerRestFixtureBuilder::new(FixtureService)
+ .auth_provider(FixtureAuthProvider)
+ .build();
+
+ let bind_addr =
+ std::env::var("PLAYWRIGHT_REST_ADDR").unwrap_or_else(|_| "127.0.0.1:3101".to_string());
+ let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
+ axum::serve(listener, app).await?;
+
+ Ok(())
+}
diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json
new file mode 100644
index 0000000..5794c72
--- /dev/null
+++ b/tests/playwright/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "ras-api-explorer-e2e",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ras-api-explorer-e2e",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@playwright/test": "1.58.2"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/tests/playwright/package.json b/tests/playwright/package.json
new file mode 100644
index 0000000..f933c92
--- /dev/null
+++ b/tests/playwright/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "ras-api-explorer-e2e",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "test": "playwright test",
+ "test:headed": "playwright test --headed",
+ "install:browsers": "playwright install --with-deps chromium"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.58.2"
+ }
+}
diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts
new file mode 100644
index 0000000..0126e22
--- /dev/null
+++ b/tests/playwright/playwright.config.ts
@@ -0,0 +1,40 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const restPort = process.env.PLAYWRIGHT_REST_PORT ?? '3101';
+const jsonrpcPort = process.env.PLAYWRIGHT_JSONRPC_PORT ?? '3102';
+
+export default defineConfig({
+ testDir: './tests',
+ fullyParallel: false,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://127.0.0.1',
+ launchOptions: {
+ slowMo: Number(process.env.PLAYWRIGHT_SLOW_MO ?? 0)
+ },
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure'
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] }
+ }
+ ],
+ webServer: [
+ {
+ command: `PLAYWRIGHT_REST_ADDR=127.0.0.1:${restPort} cargo run -p playwright-rest-fixture`,
+ url: `http://127.0.0.1:${restPort}/api/v1/docs/openapi.json`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 240_000
+ },
+ {
+ command: `PLAYWRIGHT_JSONRPC_ADDR=127.0.0.1:${jsonrpcPort} cargo run -p playwright-jsonrpc-fixture`,
+ url: `http://127.0.0.1:${jsonrpcPort}/rpc/explorer/openrpc.json`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 240_000
+ }
+ ]
+});
diff --git a/tests/playwright/tests/jsonrpc-explorer.spec.ts b/tests/playwright/tests/jsonrpc-explorer.spec.ts
new file mode 100644
index 0000000..d10376e
--- /dev/null
+++ b/tests/playwright/tests/jsonrpc-explorer.spec.ts
@@ -0,0 +1,123 @@
+import { expect, test, type Page } from '@playwright/test';
+
+const RPC_PORT = process.env.PLAYWRIGHT_JSONRPC_PORT ?? '3102';
+const RPC_URL = `http://127.0.0.1:${RPC_PORT}/rpc/explorer`;
+
+async function selectMethod(page: Page, name: string) {
+ await page.locator('.op').filter({ hasText: name }).click();
+}
+
+async function send(page: Page) {
+ await page.locator('#send-request').click();
+}
+
+test.describe('JSON-RPC API explorer', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(RPC_URL);
+ await expect(page.locator('#service-name')).toContainText('ExplorerRpcFixture Explorer');
+ await expect(page.locator('#operation-list .op').first()).toBeVisible();
+ });
+
+ test('loads in dark mode and renders OpenRPC methods', async ({ page }) => {
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
+ await expect(page.locator('#service-subtitle')).toContainText('JSON-RPC OpenRPC');
+ await expect(page.locator('#operation-list')).toContainText('ping');
+ await expect(page.locator('#operation-list')).toContainText('create_widget');
+ await expect(page.locator('#operation-list')).toContainText('current_profile');
+ });
+
+ test('searches methods and switches params editor without stale UI', async ({ page }) => {
+ await page.locator('#operation-search').fill('create');
+ await expect(page.locator('.op').filter({ hasText: 'create_widget' })).toBeVisible();
+ await expect(page.locator('.op').filter({ hasText: 'ping' })).toHaveCount(0);
+
+ await page.locator('#operation-search').fill('');
+ await selectMethod(page, 'ping');
+ await expect(page.locator('#params-editor')).toBeVisible();
+ await page.locator('#params-editor').fill(JSON.stringify({ message: 'hello' }, null, 2));
+ await expect(page.locator('#request-url')).toHaveText(`http://127.0.0.1:${RPC_PORT}/rpc`);
+
+ await selectMethod(page, 'no_params');
+ await expect(page.locator('#params-editor')).toHaveCount(0);
+ await expect(page.locator('#request-form')).toContainText('This method has no params.');
+
+ await selectMethod(page, 'create_widget');
+ await expect(page.locator('#params-editor')).toBeVisible();
+ await expect(page.locator('#params-editor')).not.toHaveValue(/hello/);
+ });
+
+ test('sends public and authenticated JSON-RPC requests', async ({ page }) => {
+ await selectMethod(page, 'ping');
+ await page.locator('#params-editor').fill(JSON.stringify({ message: 'browser' }, null, 2));
+ const originalRequestId = await page.locator('#rpc-request-id').inputValue();
+ await page.getByRole('button', { name: 'Regenerate' }).click();
+ await expect(page.locator('#rpc-request-id')).not.toHaveValue(originalRequestId);
+
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('200');
+ await expect(page.locator('#response-output')).toContainText('pong: browser');
+ await expect(page.locator('#history-list')).toContainText('RPC ping');
+
+ await selectMethod(page, 'create_widget');
+ await page.locator('#params-editor').fill(JSON.stringify({ name: 'RPC Widget', owner: 'playwright' }, null, 2));
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('RPC error');
+ await expect(page.locator('#response-output')).toContainText('Authentication');
+
+ await page.locator('#jwt-token').fill('admin-token');
+ await page.locator('#save-token').click();
+ await expect(page.locator('#auth-state')).toContainText('Token set');
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('200');
+ await expect(page.locator('#response-output')).toContainText('rpc-created-widget');
+ await expect(page.locator('#response-output')).toContainText('RPC Widget');
+
+ await page.locator('[data-response-tab="request"]').click();
+ await expect(page.locator('#response-output')).toContainText('create_widget');
+ await expect(page.locator('#response-output')).toContainText('RPC Widget');
+ });
+
+ test('saves JSON-RPC requests, restores history, and keeps tokens out of localStorage', async ({ page }) => {
+ await selectMethod(page, 'create_widget');
+ await page.locator('#jwt-token').fill('admin-token');
+ await page.locator('#save-token').click();
+ await page.locator('#params-editor').fill(JSON.stringify({ name: 'Saved RPC', owner: 'saved-owner' }, null, 2));
+
+ page.once('dialog', async (dialog) => {
+ await dialog.accept('rpc saved request');
+ });
+ await page.locator('#save-request').click();
+ await expect(page.locator('#saved-list')).toContainText('rpc saved request');
+
+ await page.locator('#params-editor').fill(JSON.stringify({ name: 'Changed RPC', owner: 'changed' }, null, 2));
+ await page.locator('#saved-list').getByRole('button', { name: 'Load' }).click();
+ await expect(page.locator('#params-editor')).toHaveValue(/Saved RPC/);
+
+ await send(page);
+ await expect(page.locator('#history-list')).toContainText('RPC create_widget');
+ await page.locator('#params-editor').fill(JSON.stringify({ name: 'After History', owner: 'changed' }, null, 2));
+ await page.locator('#history-list').getByRole('button', { name: 'Load request' }).first().click();
+ await expect(page.locator('#params-editor')).toHaveValue(/Saved RPC/);
+
+ await page.reload();
+ await expect(page.locator('#service-name')).toContainText('ExplorerRpcFixture Explorer');
+ await selectMethod(page, 'create_widget');
+ await expect(page.locator('#saved-list')).toContainText('rpc saved request');
+ await expect(page.locator('#history-list')).toContainText('RPC create_widget');
+
+ const localStorageValues = await page.evaluate(() => Object.values(localStorage).join('\n'));
+ expect(localStorageValues).not.toContain('admin-token');
+ expect(localStorageValues).not.toContain('user-token');
+ const sessionStorageValues = await page.evaluate(() => Object.values(sessionStorage).join('\n'));
+ expect(sessionStorageValues).toContain('admin-token');
+ });
+
+ test('persists theme preference in localStorage', async ({ page }) => {
+ await page.locator('#theme-toggle').click();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ const theme = await page.evaluate(() => localStorage.getItem('ras-explorer-theme'));
+ expect(theme).toBe('light');
+ });
+});
diff --git a/tests/playwright/tests/rest-explorer.spec.ts b/tests/playwright/tests/rest-explorer.spec.ts
new file mode 100644
index 0000000..d42a983
--- /dev/null
+++ b/tests/playwright/tests/rest-explorer.spec.ts
@@ -0,0 +1,133 @@
+import { expect, test, type Page } from '@playwright/test';
+
+const REST_PORT = process.env.PLAYWRIGHT_REST_PORT ?? '3101';
+const REST_URL = `http://127.0.0.1:${REST_PORT}/api/v1/docs`;
+
+async function selectOperation(page: Page, method: string, path: string) {
+ await page.locator('.op').filter({ hasText: method }).filter({ hasText: path }).click();
+}
+
+async function send(page: Page) {
+ await page.locator('#send-request').click();
+}
+
+test.describe('REST API explorer', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(REST_URL);
+ await expect(page.locator('#service-name')).toContainText('ExplorerRestFixture Explorer');
+ await expect(page.locator('#operation-list .op').first()).toBeVisible();
+ });
+
+ test('loads in dark mode and renders OpenAPI operations', async ({ page }) => {
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
+ await expect(page.locator('#service-subtitle')).toContainText('REST OpenAPI');
+ await expect(page.locator('#operation-list')).toContainText('/health');
+ await expect(page.locator('#operation-list')).toContainText('/widgets');
+ await expect(page.locator('#operation-list')).toContainText('/search/widgets');
+ });
+
+ test('searches operations and switches request forms without stale UI', async ({ page }) => {
+ await page.locator('#operation-search').fill('search');
+ await expect(page.locator('.op').filter({ hasText: '/search/widgets' })).toBeVisible();
+ await expect(page.locator('.op').filter({ hasText: '/health' })).toHaveCount(0);
+
+ await page.locator('#operation-search').fill('');
+ await selectOperation(page, 'GET', '/search/widgets');
+ await page.locator('[data-query-param="q"]').fill('alpha');
+ await page.locator('[data-query-param="limit"]').fill('2');
+ await expect(page.locator('#request-url')).toContainText('/api/v1/search/widgets');
+ await expect(page.locator('#request-url')).toContainText('q=alpha');
+ await expect(page.locator('#request-url')).toContainText('limit=2');
+
+ await selectOperation(page, 'GET', '/widgets/{id}');
+ await expect(page.locator('[data-query-param="q"]')).toHaveCount(0);
+ await page.locator('[data-path-param="id"]').fill('widget-123');
+ await expect(page.locator('#request-url')).toContainText('/api/v1/widgets/widget-123');
+
+ await selectOperation(page, 'GET', '/health');
+ await expect(page.locator('#body-editor')).toHaveCount(0);
+ await expect(page.locator('[data-path-param="id"]')).toHaveCount(0);
+ });
+
+ test('sends public and authenticated requests, then records history', async ({ page }) => {
+ await selectOperation(page, 'GET', '/health');
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('200');
+ await expect(page.locator('#response-output')).toContainText('"status": "ok"');
+ await expect(page.locator('#history-list')).toContainText('GET /health');
+
+ await selectOperation(page, 'POST', '/widgets');
+ await page.locator('#body-editor').fill(JSON.stringify({ name: 'Created From UI', owner: 'playwright' }, null, 2));
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('401');
+
+ await page.locator('#jwt-token').fill('admin-token');
+ await page.locator('#save-token').click();
+ await expect(page.locator('#auth-state')).toContainText('Token set');
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('201');
+ await expect(page.locator('#response-output')).toContainText('created-widget');
+ await expect(page.locator('#response-output')).toContainText('Created From UI');
+
+ await page.locator('[data-response-tab="headers"]').click();
+ await expect(page.locator('#response-output')).toContainText('content-type');
+ await page.locator('[data-response-tab="request"]').click();
+ await expect(page.locator('#response-output')).toContainText('Created From UI');
+ });
+
+ test('shows permission denied responses for insufficient token permissions', async ({ page }) => {
+ await selectOperation(page, 'POST', '/widgets');
+ await page.locator('#body-editor').fill(JSON.stringify({ name: 'Denied Widget', owner: 'playwright' }, null, 2));
+ await page.locator('#jwt-token').fill('user-token');
+ await page.locator('#save-token').click();
+ await expect(page.locator('#auth-state')).toContainText('Token set');
+
+ await send(page);
+ await expect(page.locator('#response-status')).toContainText('403');
+ await expect(page.locator('#history-list')).toContainText('403');
+ });
+
+ test('saves requests, restores history, and keeps tokens out of localStorage', async ({ page }) => {
+ await selectOperation(page, 'POST', '/widgets');
+ await page.locator('#jwt-token').fill('admin-token');
+ await page.locator('#save-token').click();
+ await page.locator('#body-editor').fill(JSON.stringify({ name: 'Saved Body', owner: 'saved-owner' }, null, 2));
+
+ page.once('dialog', async (dialog) => {
+ await dialog.accept('rest saved request');
+ });
+ await page.locator('#save-request').click();
+ await expect(page.locator('#saved-list')).toContainText('rest saved request');
+
+ await page.locator('#body-editor').fill(JSON.stringify({ name: 'Changed Body', owner: 'changed' }, null, 2));
+ await page.locator('#saved-list').getByRole('button', { name: 'Load' }).click();
+ await expect(page.locator('#body-editor')).toHaveValue(/Saved Body/);
+
+ await send(page);
+ await expect(page.locator('#history-list')).toContainText('POST /widgets');
+ await page.locator('#body-editor').fill(JSON.stringify({ name: 'After History', owner: 'changed' }, null, 2));
+ await page.locator('#history-list').getByRole('button', { name: 'Load request' }).first().click();
+ await expect(page.locator('#body-editor')).toHaveValue(/Saved Body/);
+
+ await page.reload();
+ await expect(page.locator('#service-name')).toContainText('ExplorerRestFixture Explorer');
+ await selectOperation(page, 'POST', '/widgets');
+ await expect(page.locator('#saved-list')).toContainText('rest saved request');
+ await expect(page.locator('#history-list')).toContainText('POST /widgets');
+
+ const localStorageValues = await page.evaluate(() => Object.values(localStorage).join('\n'));
+ expect(localStorageValues).not.toContain('admin-token');
+ expect(localStorageValues).not.toContain('user-token');
+ const sessionStorageValues = await page.evaluate(() => Object.values(sessionStorage).join('\n'));
+ expect(sessionStorageValues).toContain('admin-token');
+ });
+
+ test('persists theme preference in localStorage', async ({ page }) => {
+ await page.locator('#theme-toggle').click();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
+ const theme = await page.evaluate(() => localStorage.getItem('ras-explorer-theme'));
+ expect(theme).toBe('light');
+ });
+});