feat: integrate Workspace Web Drive management
Merge approved T-724 sourcee4daa4aainto current developa91eec29without conflicts. Root check, Server714+11 and explicit real SQLite/LocalFileSystem/Web HTTP adapter, API TypeScript91 and generated contracts, Web check0/0/unit569/component305/build/browser9/responsive1 all passed. Fixture-browser results are distinct from real storage/auth tests; no live deployment update.
This commit is contained in:
@@ -32765,6 +32765,7 @@ mod tests {
|
||||
mod auth_logging_tests;
|
||||
mod drive_api_tests;
|
||||
mod drive_tests;
|
||||
mod drive_web_roundtrip_tests;
|
||||
mod subject_spawn_tests;
|
||||
mod subjektiv_jobs_tests;
|
||||
mod ticket_evidence_tests;
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! T-724's real API/SQLite/FeatureStorage/LocalFileSystem boundary, separate
|
||||
//! from browser fixtures. The Deno child imports the production Web adapter.
|
||||
use super::*;
|
||||
use server_api::*;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
struct LoopbackServer {
|
||||
origin: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl LoopbackServer {
|
||||
async fn start(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
Self { origin, task }
|
||||
}
|
||||
}
|
||||
impl Drop for LoopbackServer {
|
||||
fn drop(&mut self) {
|
||||
// No detached listener survives success, panic, or child timeout.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
struct RealDriveFixture {
|
||||
// TempDir outlives the API and listener; no user's DB/data directory is used.
|
||||
server: LoopbackServer,
|
||||
api: WorkspaceApi,
|
||||
owner: String,
|
||||
second: String,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
impl RealDriveFixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(temp.path());
|
||||
config.repositories.clear();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
|
||||
let second = seed_test_api_token(store.as_ref(), "drive-web-second");
|
||||
let server_api = WorkspaceServerApi::new(config, store.clone());
|
||||
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
|
||||
// Exercise T-717's real repositoryless Workspace bootstrap, not a
|
||||
// pre-existing test Workspace with its repository merely hidden in UI.
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{}/api/workspaces", server.origin))
|
||||
.bearer_auth(&owner)
|
||||
.json(&json!({"operation_key":"drive-web-bootstrap","display_name":"Drive only"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let created = http_json(response, StatusCode::CREATED).await;
|
||||
assert!(created["repository"].is_null(), "{created}");
|
||||
let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap();
|
||||
assert!(store.list_repositories(workspace_id).unwrap().is_empty());
|
||||
let api = server_api
|
||||
.api_for_workspace(workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(api.config.repositories.is_empty());
|
||||
// Cache the same production scoped router without starting unrelated
|
||||
// orchestrator subscription tasks: this fixture tests Drive HTTP, not
|
||||
// Runtime/Tools/WIP scheduling. The server's ingress/auth still runs.
|
||||
server_api
|
||||
.routers
|
||||
.lock()
|
||||
.await
|
||||
.insert(workspace_id.to_owned(), build_inner_router(api.clone()));
|
||||
Self {
|
||||
server,
|
||||
api,
|
||||
owner,
|
||||
second,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
|
||||
fn base(&self) -> String {
|
||||
format!("/api/w/{}/drive", self.api.workspace_id())
|
||||
}
|
||||
|
||||
async fn user(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
expected: StatusCode,
|
||||
) -> Value {
|
||||
let mut request = reqwest::Client::new()
|
||||
.request(method, format!("{}{path}", self.server.origin))
|
||||
.bearer_auth(&self.owner);
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
http_json(request.send().await.unwrap(), expected).await
|
||||
}
|
||||
|
||||
async fn seed_workers(&self) -> (RuntimeIdentityMaterial, Vec<RuntimeWorkerRef>) {
|
||||
let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID;
|
||||
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||
let binding = WorkspaceRuntimeBinding {
|
||||
workspace_id: self.api.workspace_id().into(),
|
||||
runtime_id: runtime_id.into(),
|
||||
display_name: "Drive fixture runtime".into(),
|
||||
base_url: "https://runtime.example.invalid".into(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
state: StoredRuntimeBindingState::Verified,
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_key_generation: None,
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
};
|
||||
self.api
|
||||
.store
|
||||
.upsert_workspace_runtime_binding_record(binding, false)
|
||||
.await
|
||||
.unwrap();
|
||||
// Keep the production runtime trust gate: its expected snapshot must
|
||||
// match the just-seeded normalized durable binding, not bypass it.
|
||||
let current_binding = self
|
||||
.api
|
||||
.store
|
||||
.get_workspace_runtime_binding(self.api.workspace_id(), runtime_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
self.api
|
||||
.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(
|
||||
(self.api.workspace_id().to_owned(), runtime_id.to_owned()),
|
||||
current_binding,
|
||||
);
|
||||
// Only the runtime process boundary is a deterministic fake. Workspace,
|
||||
// workers, grants, receipts, Drive nodes and content are real authorities.
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
self.api.runtime.register_or_replace(runtime.clone());
|
||||
let mut workers = vec![];
|
||||
for (name, access) in [
|
||||
("Drive reader", "read_only"),
|
||||
("Drive writer", "read_write"),
|
||||
] {
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(self.api.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: runtime_id.into(),
|
||||
display_name: name.into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:coder".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
|
||||
// The reusable fixture defaults to TEST_WORKSPACE_ID. Its runtime
|
||||
// observation must identify this genuinely bootstrapped Workspace.
|
||||
{
|
||||
let mut observed = runtime.workers.lock().unwrap();
|
||||
let summary = observed
|
||||
.iter_mut()
|
||||
.find(|summary| summary.worker == worker)
|
||||
.unwrap();
|
||||
summary.workspace.identity = self.api.workspace_id().to_owned();
|
||||
summary.workspace.workspace_id = Some(self.api.workspace_id().to_owned());
|
||||
}
|
||||
assert!(
|
||||
self.api
|
||||
.store
|
||||
.list_worker_workdir_links(self.api.workspace_id(), &worker)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
self.user(reqwest::Method::POST, &format!("{}/grants", self.base()),
|
||||
Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})), StatusCode::OK).await;
|
||||
workers.push(worker);
|
||||
}
|
||||
(identity, workers)
|
||||
}
|
||||
|
||||
async fn worker(
|
||||
&self,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
worker: &RuntimeWorkerRef,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
expected: StatusCode,
|
||||
) -> Value {
|
||||
let bytes = body
|
||||
.map(|v| serde_json::to_vec(&v).unwrap())
|
||||
.unwrap_or_default();
|
||||
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(identity)
|
||||
.issue(
|
||||
self.api.config.backend_base_url.as_deref().unwrap(),
|
||||
self.api.workspace_id(),
|
||||
Some(&worker.worker_id),
|
||||
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||
method,
|
||||
path,
|
||||
&bytes,
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
let response = reqwest::Client::new()
|
||||
.request(
|
||||
reqwest::Method::from_bytes(method.as_bytes()).unwrap(),
|
||||
format!("{}{path}", self.server.origin),
|
||||
)
|
||||
.bearer_auth(&self.owner)
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
proof,
|
||||
)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
http_json(response, expected).await
|
||||
}
|
||||
|
||||
async fn worker_artifact(&self) -> Value {
|
||||
let (identity, workers) = self.seed_workers().await;
|
||||
let root = self
|
||||
.user(
|
||||
reqwest::Method::GET,
|
||||
&format!("{}/root", self.base()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let mutation = json!({"request_id":"worker-artifact","mutation":{"operation":"create_text","parent":root["entry"],"name":"worker.md","text":"# Worker artifact\n","content_type":"text/markdown"}});
|
||||
let denied = self
|
||||
.worker(
|
||||
&identity,
|
||||
&workers[0],
|
||||
"POST",
|
||||
&format!("{}/mutate", self.base()),
|
||||
Some(mutation.clone()),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied["code"], "denied");
|
||||
let saved = self
|
||||
.worker(
|
||||
&identity,
|
||||
&workers[1],
|
||||
"POST",
|
||||
&format!("{}/mutate", self.base()),
|
||||
Some(mutation),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let read_path = format!(
|
||||
"{}/read-text?entry_workspace_id={}&id={}",
|
||||
self.base(),
|
||||
self.api.workspace_id(),
|
||||
saved["entry"]["entry"]["node_id"].as_str().unwrap()
|
||||
);
|
||||
let read = self
|
||||
.worker(
|
||||
&identity,
|
||||
&workers[0],
|
||||
"GET",
|
||||
&read_path,
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(read["text"], "# Worker artifact\n");
|
||||
// Revocation is observed by the next signed HTTP request, including one
|
||||
// carrying the owner's bearer token (runtime identity has precedence).
|
||||
let grants = self
|
||||
.user(
|
||||
reqwest::Method::GET,
|
||||
&format!("{}/grants", self.base()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let grant = grants["grants"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|grant| grant["worker_id"] == workers[0].worker_id)
|
||||
.unwrap();
|
||||
self.user(
|
||||
reqwest::Method::DELETE,
|
||||
&format!(
|
||||
"{}/grants/{}",
|
||||
self.base(),
|
||||
grant["grant_id"].as_str().unwrap()
|
||||
),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let revoked = self
|
||||
.worker(
|
||||
&identity,
|
||||
&workers[0],
|
||||
"GET",
|
||||
&read_path,
|
||||
None,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(revoked["code"], "denied");
|
||||
let web = self
|
||||
.user(reqwest::Method::GET, &read_path, None, StatusCode::OK)
|
||||
.await;
|
||||
assert_eq!(web["text"], "# Worker artifact\n");
|
||||
saved["entry"].clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_json(response: reqwest::Response, expected: StatusCode) -> Value {
|
||||
let status = response.status();
|
||||
let bytes = response.bytes().await.unwrap();
|
||||
let value: Value =
|
||||
serde_json::from_slice(&bytes).unwrap_or_else(|_| json!(String::from_utf8_lossy(&bytes)));
|
||||
assert_eq!(status.as_u16(), expected.as_u16(), "{value}");
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repositoryless_drive_shares_worker_bytes_and_enforces_current_read_only_grants_over_http()
|
||||
{
|
||||
let fixture = RealDriveFixture::new().await;
|
||||
let artifact = fixture.worker_artifact().await;
|
||||
let root = fixture
|
||||
.user(
|
||||
reqwest::Method::GET,
|
||||
&format!("{}/root", fixture.base()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let page = fixture
|
||||
.user(
|
||||
reqwest::Method::GET,
|
||||
&format!(
|
||||
"{}/list?entry_workspace_id={}&id={}",
|
||||
fixture.base(),
|
||||
fixture.api.workspace_id(),
|
||||
root["entry"]["node_id"].as_str().unwrap()
|
||||
),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(page["entries"], json!([artifact]));
|
||||
// Real LocalFileSystem persisted the Worker bytes; no mock object store.
|
||||
let blob_root = fixture.api.config.drive_blob_root().unwrap();
|
||||
let blobs: Vec<_> = fs::read_dir(blob_root)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(blobs.len(), 1);
|
||||
assert_eq!(fs::read(&blobs[0]).unwrap(), b"# Worker artifact\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "explicit API/Web process roundtrip; requires Deno on PATH (see web/workspace/test/drive-real-api/README.md)"]
|
||||
async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle()
|
||||
{
|
||||
let fixture = RealDriveFixture::new().await;
|
||||
let worker_entry = fixture.worker_artifact().await;
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let web = root.join("web/workspace");
|
||||
let mut child = tokio::process::Command::new("deno")
|
||||
.current_dir(&web)
|
||||
.args([
|
||||
"run",
|
||||
"--cached-only",
|
||||
"--config",
|
||||
"test/drive-real-api/deno.json",
|
||||
])
|
||||
.arg(format!(
|
||||
"--allow-net={}",
|
||||
fixture.server.origin.trim_start_matches("http://")
|
||||
))
|
||||
.arg("test/drive-real-api/roundtrip.ts")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("Deno must be installed for this explicit process test");
|
||||
let wire = serde_json::to_vec(&json!({
|
||||
"origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(),
|
||||
"owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
stdin.write_all(&wire).await.unwrap();
|
||||
drop(stdin);
|
||||
// Timeout is only a hang bound; listener readiness is the successful bind
|
||||
// and child synchronization is completion, never sleeps or polling loops.
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(60), child.wait_with_output())
|
||||
.await
|
||||
.expect("Deno real API roundtrip exceeded 60 seconds")
|
||||
.unwrap();
|
||||
// Defensive redaction: test failure diagnostics must never print credentials.
|
||||
let redact = |bytes: &[u8]| {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.replace(&fixture.owner, "[redacted]")
|
||||
.replace(&fixture.second, "[redacted]")
|
||||
};
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Deno real API roundtrip failed:\n{}\n{}",
|
||||
redact(&output.stdout),
|
||||
redact(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("real Web adapter roundtrip passed"));
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.store
|
||||
.list_repositories(fixture.api.workspace_id())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Workspace Web Drive
|
||||
|
||||
The Web Drive is a latest-only shared Workspace resource, independent of Git, repositories, Worker sessions and Workdir occupancy. The Server's DB node tree, revision, receipt and current authorization are authoritative. The UI never uses physical blob keys/paths or stores a second persistent hierarchy.
|
||||
|
||||
## References and authorization
|
||||
|
||||
`/w/<workspace>/drive/<node-id>` is a human-facing stable latest URL. Its node ID and revisions remain canonical positive decimal **strings** (including values above JavaScript's safe-integer range). Rename/move retain the URL; deleting and recreating a name does not revive the deleted ID. Copy latest URL emits this Web route, not the API's safe binary download URL. Ticket/Objective/comment Markdown can link to this route. Possessing any URL/ID grants no access: metadata, content, mutations, download and receipt queries all use the current authenticated API.
|
||||
|
||||
Ordinary Drive usage retains the existing member-facing API policy, not owner-only settings policy. Worker grants are owner-controlled on the existing Workers page, using its current authoritative Worker catalog. Read-only/read-write are explicit Backend grants, not consequences of enabling a Profile tool. The UI does not invent a Web read-only ACL: Backend denial is displayed and suppresses mutations. Worker read-only denial and revoke are separately exercised against the real API.
|
||||
|
||||
## Drafts, revision and delayed operations
|
||||
|
||||
The optional node route reuses one controller across Workspace/entry switches. Drafts and mutation receipts are keyed by Workspace/node identity in this page session. Reads/writes/status/list/search/ancestor/image completion are generation fenced, including A→B→A. No delayed response can select a new target or change its draft/save destination. Leaving the Drive page prompts before destroying a dirty draft or unresolved receipt; they are not claimed to survive a browser reload.
|
||||
|
||||
Text edits use the read revision as `expected_revision`. A conflict preserves the draft and that revision, displays an explicit conflict and never silently rebases, automerges or resubmits. Refresh can show the latest saved text alongside the draft. Discard/load-latest is an explicit destructive user decision. Partial previews cannot be saved. Save acknowledgments retain edits made while saving.
|
||||
|
||||
## Transfer and media policy
|
||||
|
||||
- Markdown/plain text: 64 KiB UTF-8 read/edit limit. Markdown uses the existing raw HTML-rejecting safe document renderer. Other active/binary media are download-only.
|
||||
- Raster image preview: PNG/JPEG/GIF/WebP, authenticated revision-bound fetch, 256 KiB bounded bytes/Blob; decode/load failure is explicit. Object URLs are released on switching/destruction. Larger images remain downloadable.
|
||||
- Download: native same-origin authenticated binary endpoint, pinned revision; Server's attachment/nosniff/sandbox/no-store headers remain authoritative.
|
||||
- Markdown inline images never automatically fetch external/cross-Workspace/API resources. They display alternative text and require explicit navigation to an authorized Drive URL. Same-Workspace resource links navigate normally; external HTTP(S) links have no userinfo, open a new tab with `noreferrer`; script/data/file, protocol-relative, arbitrary API/relative paths are not links.
|
||||
- Upload/replacement: at most 16 MiB raw Blob request, not JSON/base64 or DOM bytes. SHA-256 needs one bounded ArrayBuffer; there is no constant-memory browser hashing claim. Transfer/pending is distinct from the DB-published acknowledgment.
|
||||
- A request ID is allocated before a mutation. Transport/response loss or abort after transmission is conservatively unknown, not success/failure. Its current authorized receipt endpoint is queried; no new request or automatic reupload is generated. `uncommitted` means no commit observed yet, not proof that an in-flight publication cannot still finish. Unknown requests block new writes to that entry.
|
||||
|
||||
Name conflict, revision conflict, limit, denied, missing node, storage/I/O failure and outcome unknown are not translated into empty contents/empty Drive/success. Initial folder deletion is empty-only with explicit confirmation; no recursive operation or blob-copy rename is introduced.
|
||||
|
||||
## Validation
|
||||
|
||||
Browser API fixtures and real authority evidence are intentionally separate:
|
||||
|
||||
- `web/workspace/src/lib/workspace/drive/*test.ts`: malformed payload/ID and transport/state races at the client/controller boundary.
|
||||
- `drive-ui`/`drive-grants` component tests: actual safe rendering/interaction.
|
||||
- `web/workspace/test/drive-browser`: production static shell + synthetic API fixtures, pinned Chromium interactions and visual evidence.
|
||||
- `crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs` and `web/workspace/test/drive-real-api`: hermetic real Server HTTP, SQLite, FeatureStorage and LocalFileSystem, repositoryless bootstrap and production Web adapter. Only the Runtime process boundary is fake; no live dogfood data is used.
|
||||
|
||||
See the test READMEs and T-724 validation record for exact commands/results, artifact paths, source revisions and any deliberate failing fixture state.
|
||||
@@ -0,0 +1,7 @@
|
||||
# T-724: omitted SubWorker cwd can omit the checkout attachment
|
||||
|
||||
During T-724, children created with read-only `.` and restricted writable logical paths but no explicit `cwd` had inconsistent startup contexts. The API child could read the checkout; the browser and real-roundtrip children only saw their transient bash-output directory and reported no repository Workdir/command attachment. Neither blocked child edited source or ran validation. Their committed sessions were inspected before stopping them.
|
||||
|
||||
Recreating those two children with **the same readable/writable rules** and explicit `cwd: "."` at the checkout root (using the inherited Coder profile) restored repository tools. There was no denied-write bypass, broadened filesystem scope, absolute-path fallback or Backend storage mutation. The recreated children successfully ran production-shell visual captures and a real isolated SQLite/LocalFileSystem/Server/Web-adapter test respectively.
|
||||
|
||||
This repeats the omitted-cwd case described in `t718-subworker-path-context.md`. The spawn response should expose actual attachment aliases/root/tool cwd, and reject or clearly report a scope whose attachment could not be bound. The filesystem logical root should remain invariant; cwd is documented as a command default, not an authority grant. This report records tooling friction, not a new Drive requirement or an execution prohibition.
|
||||
@@ -0,0 +1,132 @@
|
||||
# T-724 validation evidence
|
||||
|
||||
Scope: `T-724`, authoritative item `00001M4D5ZN6P:4`, resume instruction thread
|
||||
sequence 7. Baseline: `c79506b60f1abcb3d8698d01491f2b6e644170d0` (clean detached
|
||||
checkout). Source branch: `work/T-724-workspace-drive`. T-717/T-722 are integrated;
|
||||
T-723 Tools/WIP and T-714 implementation are not modified.
|
||||
|
||||
## Requirements → observable proof
|
||||
|
||||
| Requirement | Implementation / proof |
|
||||
| --- | --- |
|
||||
| Shared Workspace asset, no Repository/Git prerequisite | Workspace sidebar Drive; optional-node route; real `/api/workspaces` repositoryless bootstrap, zero repository records/config, folder/Markdown/PNG/empty-file and Worker artifact roundtrip. |
|
||||
| DB hierarchy, stable string node URL | `driveHref`, strict decimal/ref/metadata parsers; root/list/parent-chain APIs only; rename/move/delete/recreate lifecycle in real adapter and production browser. No blob path or second persisted tree. |
|
||||
| Paged folder list, metadata and mutations | Service cursors, bounded pages, breadcrumb, search, creation, native binary download, replace, relocate, empty-folder delete; real and fixture roundtrips. |
|
||||
| CAS and draft retention | Per-Workspace/node draft with read revision; 2-account real concurrent CAS (one success/one conflict), 2-tab browser drafts, explicit refresh vs discard; component regression proves open rename form does not silently rebase on refresh. |
|
||||
| Identity/revision fences | Controller selection/write/page epochs; saved tuple, scoped receipts and separate cancel generation; delayed old read/save/status/workspace browser gates; keyed image decode events and URL revocation component regression. |
|
||||
| Safe Markdown/raster/binary | Existing raw-HTML-rejecting renderer; same-Workspace explicit resource navigation and credential-free external HTTP(S), no automatic inline image requests; real revision-bound PNG transfer, 256 KiB raster bound, HTML/SVG download-only, failed image/truncated/empty browser states. |
|
||||
| Upload publication / errors / OutcomeUnknown | Raw Blob ≤16 MiB + SHA-256; transfer receipt vs DB-published acknowledgment; cancellation/loss preserve request ID and query current-authorized result, no unconditional reupload; typed name/CAS/limit/denied/storage failures. |
|
||||
| Backend current permission and Worker grants | Existing authoritative Worker catalog; owner setting flag; explicit read_only/read_write, separate Profile enablement, complete paged grants, revoke; real signed read-only Worker denial/revoke and exact Web grant-adapter owner/member HTTP boundaries; component and browser grant interactions. |
|
||||
| Latest only / no added workflow | Explicit replacement/history warning; no versions, approval/publication workflow, public share links or recursive folder delete. |
|
||||
|
||||
## Real API/DB/LocalFileSystem evidence (not fixture responses)
|
||||
|
||||
`crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs` creates a
|
||||
fresh temporary SQLite/FeatureStorage/LocalFileSystem Workspace over real loopback
|
||||
HTTP. It starts no live dogfood Server or Runtime; the Runtime Worker-spawn process
|
||||
boundary is fake, while Workspace/Worker registry/grants/nodes/bytes/receipts and
|
||||
request authentication are production authorities.
|
||||
|
||||
The explicit Deno child imports the **production** content and grant adapters.
|
||||
Fresh credentials arrive via stdin; origin/auth resolution is loopback-bound and
|
||||
responses are never mocked. This proves authenticated HTTP adapters, **not** a
|
||||
Passkey/cookie browser login or complete live Runtime process E2E.
|
||||
|
||||
- Ordinary real HTTP test: **1 passed / 1 explicitly ignored** process test.
|
||||
- Explicit process test (`--ignored`): **1 passed**, including owner grant
|
||||
read_only/read_write create/list/revoke/re-read and non-owner administration
|
||||
rejection after parent integration.
|
||||
- Full changed Server crate, replayed by the parent before commit: **693 library +
|
||||
11 binary passed**, 1 explicitly ignored; doc-tests passed
|
||||
(`target/t724-server-final.log`).
|
||||
- Parent real adapter replay: `target/t724-real-adapter-final.log` (**1 passed**).
|
||||
Ordinary HTTP coverage is also part of the full Server crate run.
|
||||
|
||||
Reproduce using `web/workspace/test/drive-real-api/README.md` or
|
||||
`deno task test:drive:real-api` from `web/workspace`.
|
||||
|
||||
## Compile, generated contracts and Web semantic validation
|
||||
|
||||
- Workspace root `cargo check`: pass (`target/t724-root-check-final.log`).
|
||||
- `cargo test -p server-api --features typescript`: **91 passed**, doc-tests pass.
|
||||
- Canonical OpenAPI `export_openapi -- --check openapi/server-api.json`: current.
|
||||
- Regenerated Drive TypeScript, Deno-formatted to the checked-in style: byte equal
|
||||
to `web/workspace/src/lib/generated/drive-api.ts`; no generated contract edits.
|
||||
- `generate_repository_openapi_types`, `generate_runtime_api_types`,
|
||||
`generate_ticket_api_types`, `generate_companion_api_types`,
|
||||
`generate_worker_launch_api_types` with `--check`: all current.
|
||||
- `cargo fmt --all -- --check`, Deno format/lint of new typed boundaries, and
|
||||
`git diff --check HEAD`: pass.
|
||||
- Web `deno task check`: **0 errors / 0 warnings**.
|
||||
- Web `deno task test`: **566 passed**, including 36 content/controller boundary
|
||||
tests and 2 preview/link-policy tests.
|
||||
- Web `deno task test:component`: **302 passed / 32 files**, including actual
|
||||
Markdown rendering/alt labels, late decode fences, pinned form revision, grant
|
||||
administration, and dependent existing Worker/Console/Markdown surfaces.
|
||||
- Separate fixture-client compatibility tests: **4 passed**. These prove mock
|
||||
wire compatibility, not real storage or authorization.
|
||||
|
||||
Web logs: `.svelte-kit/t724-{web-check-final,unit-final,components-final}.log`,
|
||||
plus narrow `.svelte-kit/t724-{drive-state,ui-fence,markdown-tests}.log`.
|
||||
|
||||
## Production browser and visual review
|
||||
|
||||
Production rebuild and implementer-owned visual acceptance completed on
|
||||
2026-10-09 before the implementation commit:
|
||||
|
||||
- `deno task build`: pass (`.svelte-kit/t724/final-build.log`).
|
||||
- Production browser scenarios: **9 passed** (`.svelte-kit/t724/browser-final.log`).
|
||||
- Responsive/keyboard/raster audit: **1 passed**, all 1440/768/390/320 widths in
|
||||
both themes (`.svelte-kit/t724/responsive-final.{log,json}`).
|
||||
- Official after bundles: `.svelte-kit/t724/visual/t724-after-{light,dark}-committed-candidate/`,
|
||||
**108 captures per theme**. The same baseline Home/Workers personas and widths
|
||||
are compared, with additional Drive/grant states.
|
||||
- Parent opened before and after screenshot overview sheets through `ViewImage`
|
||||
(`target/t724-final-visual/{before,after}-{light,dark}-*.png`), plus original
|
||||
320px grant and 390px denied-write captures. Reviewed grouping, separators,
|
||||
spacing, focus, wrapping, page vs table scroll, and permission/recovery copy.
|
||||
Home/Workers composition is preserved except the intended shared Drive entry,
|
||||
grant section and contained Worker-table scrolling. New states remain distinct
|
||||
and narrow layouts retain accessible controls without document overflow.
|
||||
- Parent inspected official `review-context.json`: HTTP document status 200,
|
||||
accessibility snapshots and screenshot hashes present; diagnostic dispositions
|
||||
retain intentional 403/503 fixtures and superseded metadata aborts. Both
|
||||
`diagnostic-disposition.json` files have **no unexpected diagnostics**. The
|
||||
runner correctly reports `completed-with-errors` for injected failures; visual
|
||||
acceptance is the implementer's review, not that exit/status label.
|
||||
- Visual result: **pass**, no unresolved new visual regression. Artifacts use
|
||||
baseline source revision plus dirty context; production source is frozen by
|
||||
the subsequent commit (only test formatting/documentation changed afterward).
|
||||
|
||||
A final grant regression also proves catalog readiness toggles cannot abandon an
|
||||
already transmitted mutation. Readiness gates new actions but is not Worker
|
||||
identity. All **22 grant component tests** pass; pending/unknown operations remain
|
||||
fenced until their result is reconciled. Worst-case 64 KiB NUL text additionally
|
||||
proves the separate 512 KiB JSON response bound without relaxing the raster bound.
|
||||
These changes are included in the final Web check/test/build/captures above.
|
||||
|
||||
The production static Web shell is exercised against synthetic loopback APIs;
|
||||
this is separate from the real authority tests above. The fixture has a single
|
||||
Worker catalog reused by REST/subscription projections, not a second UI registry.
|
||||
See `web/workspace/test/drive-browser/README.md` for reproducible scenarios, gates,
|
||||
owned-process cleanup and the browser/visual commands.
|
||||
|
||||
Before sources are preserved under `.svelte-kit/t724/before-build/`; baseline JS
|
||||
has no Drive/grant controls. Official before bundles use
|
||||
`.svelte-kit/t724/visual/t724-before-{light,dark}-representative/`. The current
|
||||
implementation was never overwritten to produce the final static before build.
|
||||
|
||||
Intermediate visual review found and fixed:
|
||||
- Narrow Worker table widening the page grid and clipping grant controls: contain
|
||||
horizontal table scrolling and constrain the page grid; full identities remain
|
||||
available in a disclosure.
|
||||
- Markdown image placeholders losing alternate text: use the renderer library's
|
||||
actual `text` prop; retain no automatic image loading. Component and production
|
||||
browser regressions protect this.
|
||||
- Parent semantic review additionally fixed late image-decode event identity and
|
||||
open rename/replacement revision pinning, with focused component tests.
|
||||
|
||||
Injected permission/storage/list errors and superseded aborted breadcrumb reads
|
||||
remain in official context with explicit dispositions. A successful capture exit
|
||||
or HTTP200 alone is not visual acceptance. No screenshots, credential state or
|
||||
real response secrets are committed.
|
||||
@@ -0,0 +1,396 @@
|
||||
// Browser-only, in-memory API fixture. This is NOT Backend authorization/storage evidence.
|
||||
import type {
|
||||
DriveEntry,
|
||||
DriveEntryRef,
|
||||
DriveGrantResponse,
|
||||
DriveMutation,
|
||||
DriveMutationResponse,
|
||||
} from "../../../web/workspace/src/lib/generated/drive-api.ts";
|
||||
import { fixturePng } from "./raster.ts";
|
||||
|
||||
type Node = { entry: DriveEntry; bytes: Uint8Array };
|
||||
type Workspace = {
|
||||
nodes: Map<string, Node>;
|
||||
receipts: Map<string, DriveMutationResponse>;
|
||||
grants: DriveGrantResponse[];
|
||||
next: bigint;
|
||||
};
|
||||
export type FixtureLog = {
|
||||
workspace: string;
|
||||
path: string;
|
||||
method: string;
|
||||
requestId: string | null;
|
||||
nodeId: string | null;
|
||||
expectedRevision: string | null;
|
||||
};
|
||||
const encoder = new TextEncoder();
|
||||
const rootId = "9007199254740993";
|
||||
export const fixtureRootId = rootId;
|
||||
const instant = "2026-01-02T00:00:00Z";
|
||||
const errors = {
|
||||
denied: 403,
|
||||
not_found: 404,
|
||||
conflict: 409,
|
||||
invalid: 400,
|
||||
limit: 413,
|
||||
storage_unavailable: 503,
|
||||
outcome_unknown: 503,
|
||||
} as const;
|
||||
function failure(code: keyof typeof errors): Response {
|
||||
return Response.json({
|
||||
code,
|
||||
classification: code === "outcome_unknown" ? "unknown" : "not_committed",
|
||||
message: `Drive fixture ${code}`,
|
||||
}, { status: errors[code] });
|
||||
}
|
||||
export function createDriveFixture() {
|
||||
const workspaces = new Map<string, Workspace>();
|
||||
const log: FixtureLog[] = [];
|
||||
const faults = new Map<string, string>();
|
||||
function ws(id: string): Workspace {
|
||||
let state = workspaces.get(id);
|
||||
if (state) return state;
|
||||
state = { nodes: new Map(), receipts: new Map(), grants: [], next: 100n };
|
||||
workspaces.set(id, state);
|
||||
const add = (
|
||||
nodeId: string,
|
||||
name: string,
|
||||
kind: "folder" | "file",
|
||||
parent: string | null,
|
||||
contentType: string | null,
|
||||
bytes = new Uint8Array(),
|
||||
) => {
|
||||
const ref = (n: string): DriveEntryRef => ({ workspace_id: id, node_id: n });
|
||||
state!.nodes.set(nodeId, {
|
||||
bytes,
|
||||
entry: {
|
||||
entry: ref(nodeId),
|
||||
parent: parent === null ? null : ref(parent),
|
||||
name,
|
||||
kind,
|
||||
revision: "1",
|
||||
size: kind === "file" ? bytes.length : null,
|
||||
content_type: contentType,
|
||||
updated_by: "fixture-worker",
|
||||
updated_at: instant,
|
||||
latest_url: `/api/w/${encodeURIComponent(id)}/drive/${
|
||||
kind === "file" ? "download" : "metadata"
|
||||
}?entry_workspace_id=${encodeURIComponent(id)}&id=${nodeId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
add(rootId, "Drive", "folder", null, null);
|
||||
if (id.includes("empty")) return state;
|
||||
add("2", "資料", "folder", rootId, null);
|
||||
add(
|
||||
"3",
|
||||
"README.md",
|
||||
"file",
|
||||
rootId,
|
||||
"text/markdown",
|
||||
encoder.encode(
|
||||
`# Shared notes\n\nWorker-created latest-only document.\n\n[Local folder](/w/${
|
||||
encodeURIComponent(id)
|
||||
}/drive/2)\n\n[Unsafe](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n\n\n
|
||||
}/drive/5)\n`,
|
||||
),
|
||||
);
|
||||
add("4", "notes.txt", "file", "2", "text/plain", encoder.encode("Second document — 日本語"));
|
||||
add("5", "landscape.png", "file", rootId, "image/png", fixturePng);
|
||||
add("6", "empty.txt", "file", rootId, "text/plain");
|
||||
add("7", "broken.png", "file", rootId, "image/png", encoder.encode("not a valid PNG"));
|
||||
add(
|
||||
"8",
|
||||
"unsafe.svg",
|
||||
"file",
|
||||
rootId,
|
||||
"image/svg+xml",
|
||||
encoder.encode('<svg onload="alert(1)"></svg>'),
|
||||
);
|
||||
add(
|
||||
"9",
|
||||
"large.txt",
|
||||
"file",
|
||||
rootId,
|
||||
"text/plain",
|
||||
encoder.encode("bounded preview\n".repeat(5000)),
|
||||
);
|
||||
add(
|
||||
"10",
|
||||
"長い名前 — 国際化された資料と分散開発の成果物 — ".repeat(2) + ".md",
|
||||
"file",
|
||||
rootId,
|
||||
"text/markdown",
|
||||
encoder.encode("# Long name"),
|
||||
);
|
||||
add("11", "escaped-64k.txt", "file", rootId, "text/plain", new Uint8Array(65536));
|
||||
if (id.includes("paged")) {
|
||||
for (let n = 20; n < 260; n++) {
|
||||
add(
|
||||
String(n),
|
||||
`Document ${n}.txt`,
|
||||
"file",
|
||||
rootId,
|
||||
"text/plain",
|
||||
encoder.encode(String(n)),
|
||||
);
|
||||
}
|
||||
}
|
||||
state.next = id.includes("paged") ? 300n : 100n;
|
||||
return state;
|
||||
}
|
||||
function bump(node: Node) {
|
||||
node.entry.revision = String(BigInt(node.entry.revision) + 1n);
|
||||
}
|
||||
function mutate(id: string, mutation: DriveMutation): DriveEntry | null {
|
||||
const state = ws(id);
|
||||
const target = "id" in mutation ? state.nodes.get(mutation.id.node_id) : null;
|
||||
if ("id" in mutation && (mutation.id.workspace_id !== id || !target)) throw "not_found";
|
||||
if (
|
||||
target && "expected_revision" in mutation &&
|
||||
target.entry.revision !== mutation.expected_revision
|
||||
) throw "conflict";
|
||||
if ("parent" in mutation) {
|
||||
const parent = state.nodes.get(mutation.parent.node_id);
|
||||
if (mutation.parent.workspace_id !== id || parent?.entry.kind !== "folder") throw "not_found";
|
||||
}
|
||||
if ("name" in mutation) {
|
||||
if (
|
||||
!mutation.name || mutation.name.includes("/") || encoder.encode(mutation.name).length > 255
|
||||
) throw "invalid";
|
||||
if (
|
||||
[...state.nodes.values()].some((n) =>
|
||||
n !== target && n.entry.parent?.node_id === mutation.parent.node_id &&
|
||||
n.entry.name === mutation.name
|
||||
)
|
||||
) throw "conflict";
|
||||
}
|
||||
if (mutation.operation === "delete") {
|
||||
if (
|
||||
target!.entry.parent === null ||
|
||||
[...state.nodes.values()].some((n) => n.entry.parent?.node_id === mutation.id.node_id)
|
||||
) throw "conflict";
|
||||
state.nodes.delete(mutation.id.node_id);
|
||||
return null;
|
||||
}
|
||||
if (mutation.operation === "relocate") {
|
||||
target!.entry.parent = mutation.parent;
|
||||
target!.entry.name = mutation.name;
|
||||
bump(target!);
|
||||
return target!.entry;
|
||||
}
|
||||
if (mutation.operation === "update_text") {
|
||||
target!.bytes = encoder.encode(mutation.text);
|
||||
target!.entry.size = target!.bytes.length;
|
||||
target!.entry.content_type = mutation.content_type;
|
||||
bump(target!);
|
||||
return target!.entry;
|
||||
}
|
||||
const nodeId = String(state.next++);
|
||||
const bytes = mutation.operation === "create_text"
|
||||
? encoder.encode(mutation.text)
|
||||
: new Uint8Array();
|
||||
const entry: DriveEntry = {
|
||||
entry: { workspace_id: id, node_id: nodeId },
|
||||
parent: mutation.parent,
|
||||
name: mutation.name,
|
||||
kind: mutation.operation === "create_folder" ? "folder" : "file",
|
||||
revision: "1",
|
||||
size: mutation.operation === "create_folder" ? null : bytes.length,
|
||||
content_type: mutation.operation === "create_text" ? mutation.content_type : null,
|
||||
updated_by: "fixture-user",
|
||||
updated_at: instant,
|
||||
latest_url: `/api/w/${encodeURIComponent(id)}/drive/${
|
||||
mutation.operation === "create_folder" ? "metadata" : "download"
|
||||
}?entry_workspace_id=${encodeURIComponent(id)}&id=${nodeId}`,
|
||||
};
|
||||
state.nodes.set(nodeId, { entry, bytes });
|
||||
return entry;
|
||||
}
|
||||
async function handler(request: Request): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__fixture/log") return Response.json(log);
|
||||
if (url.pathname === "/__fixture/reset" && request.method === "POST") {
|
||||
workspaces.clear();
|
||||
faults.clear();
|
||||
log.length = 0;
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
if (url.pathname === "/__fixture/fault" && request.method === "POST") {
|
||||
const body = await request.json();
|
||||
faults.set(body.workspace, body.mode);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/w\/([^/]+)\/drive(\/.*)$/);
|
||||
if (!match) return null;
|
||||
const id = decodeURIComponent(match[1]), path = match[2];
|
||||
const state = ws(id);
|
||||
const mutationRoute = path === "/mutate" || path === "/upload";
|
||||
const event: FixtureLog = {
|
||||
workspace: id,
|
||||
path,
|
||||
method: request.method,
|
||||
requestId: url.searchParams.get("request_id"),
|
||||
nodeId: url.searchParams.get("id") ?? url.searchParams.get("parent_id"),
|
||||
expectedRevision: url.searchParams.get("expected_revision"),
|
||||
};
|
||||
log.push(event);
|
||||
if (id.includes("denied")) return failure("denied");
|
||||
if (id.includes("offline") || id === "home-error") return failure("storage_unavailable");
|
||||
if (mutationRoute && (id.includes("readonly") || id === "home-member")) {
|
||||
return failure("denied");
|
||||
}
|
||||
if (path.startsWith("/requests/")) {
|
||||
const requestId = decodeURIComponent(path.slice(10));
|
||||
const response = state.receipts.get(requestId) ?? null;
|
||||
return Response.json({
|
||||
request_id: requestId,
|
||||
state: response ? "committed" : "uncommitted",
|
||||
response,
|
||||
});
|
||||
}
|
||||
if (path === "/grants") {
|
||||
if (id === "home-member") return failure("denied");
|
||||
if (request.method === "POST") {
|
||||
const input = await request.json();
|
||||
const grant: DriveGrantResponse = {
|
||||
...input,
|
||||
grant_id: String(state.grants.length + 1),
|
||||
workspace_id: id,
|
||||
revoked: false,
|
||||
created_by: "fixture-owner",
|
||||
created_at: instant,
|
||||
revoked_by: null,
|
||||
revoked_at: null,
|
||||
};
|
||||
state.grants.push(grant);
|
||||
return Response.json(grant);
|
||||
}
|
||||
return Response.json({ grants: state.grants, next_after: null });
|
||||
}
|
||||
if (/^\/grants\/\d+$/.test(path) && request.method === "DELETE") {
|
||||
if (id === "home-member") return failure("denied");
|
||||
const grant = state.grants.find((g) => g.grant_id === path.split("/")[2]);
|
||||
if (!grant) return failure("not_found");
|
||||
grant.revoked = true;
|
||||
grant.revoked_at = instant;
|
||||
grant.revoked_by = "fixture-owner";
|
||||
return Response.json(grant);
|
||||
}
|
||||
if (mutationRoute) {
|
||||
try {
|
||||
const payload = path === "/mutate" ? await request.json() : null;
|
||||
const requestId = path === "/upload"
|
||||
? url.searchParams.get("request_id")!
|
||||
: payload.request_id;
|
||||
event.requestId = requestId;
|
||||
if (payload) {
|
||||
event.nodeId = payload.mutation.id?.node_id ?? payload.mutation.parent?.node_id ?? null;
|
||||
event.expectedRevision = payload.mutation.expected_revision ?? null;
|
||||
}
|
||||
if (state.receipts.has(requestId)) return Response.json(state.receipts.get(requestId));
|
||||
const fault = faults.get(id);
|
||||
if (fault === "conflict") {
|
||||
faults.delete(id);
|
||||
return failure("conflict");
|
||||
}
|
||||
let entry: DriveEntry | null;
|
||||
if (path === "/mutate") entry = mutate(id, payload.mutation);
|
||||
else {
|
||||
const bytes = new Uint8Array(await request.arrayBuffer());
|
||||
if (bytes.length > 16 * 1024 * 1024) return failure("limit");
|
||||
if (bytes.length !== Number(url.searchParams.get("size"))) return failure("invalid");
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
if (
|
||||
[...digest].map((n) => n.toString(16).padStart(2, "0")).join("") !==
|
||||
url.searchParams.get("sha256")
|
||||
) return failure("invalid");
|
||||
const content_type = url.searchParams.get("content_type")!;
|
||||
if (url.searchParams.get("operation") === "create") {
|
||||
entry = mutate(id, {
|
||||
operation: "create_text",
|
||||
parent: { workspace_id: id, node_id: url.searchParams.get("parent_id")! },
|
||||
name: url.searchParams.get("name")!,
|
||||
text: "",
|
||||
content_type,
|
||||
});
|
||||
} else {entry = mutate(id, {
|
||||
operation: "update_text",
|
||||
id: { workspace_id: id, node_id: url.searchParams.get("id")! },
|
||||
expected_revision: url.searchParams.get("expected_revision")!,
|
||||
text: "",
|
||||
content_type,
|
||||
});}
|
||||
const node = state.nodes.get(entry!.entry.node_id)!;
|
||||
node.bytes = bytes;
|
||||
node.entry.size = bytes.length;
|
||||
}
|
||||
const response = structuredClone({ request_id: requestId, entry });
|
||||
state.receipts.set(requestId, response);
|
||||
if (fault === "unknown") {
|
||||
faults.delete(id);
|
||||
return failure("outcome_unknown");
|
||||
}
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
return failure(
|
||||
typeof error === "string" && error in errors ? error as keyof typeof errors : "invalid",
|
||||
);
|
||||
}
|
||||
}
|
||||
const nodeId = path === "/root" ? rootId : url.searchParams.get("id");
|
||||
if (nodeId !== null && path !== "/root" && url.searchParams.get("entry_workspace_id") !== id) {
|
||||
return failure("invalid");
|
||||
}
|
||||
const node = nodeId ? state.nodes.get(nodeId) : null;
|
||||
if (path === "/root" || path === "/metadata") {
|
||||
return node ? Response.json(node.entry) : failure("not_found");
|
||||
}
|
||||
if (path === "/list" || path === "/search") {
|
||||
if (path === "/list" && node?.entry.kind !== "folder") return failure("not_found");
|
||||
const all = [...state.nodes.values()].filter((n) =>
|
||||
path === "/search"
|
||||
? n.entry.name.includes(url.searchParams.get("query") ?? "")
|
||||
: n.entry.parent?.node_id === nodeId
|
||||
).sort((a, b) => a.entry.name.localeCompare(b.entry.name));
|
||||
const after = url.searchParams.get("after");
|
||||
const start = after ? all.findIndex((n) => n.entry.entry.node_id === after) + 1 : 0;
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
||||
const entries = all.slice(start, start + limit).map((n) => n.entry);
|
||||
return Response.json({
|
||||
entries,
|
||||
next_after: start + limit < all.length ? entries.at(-1)!.entry.node_id : null,
|
||||
});
|
||||
}
|
||||
if (!node || node.entry.kind !== "file") return failure("not_found");
|
||||
if (path === "/read-text") {
|
||||
const text = new TextDecoder().decode(node.bytes.slice(0, 65536));
|
||||
return Response.json({ entry: node.entry, text, truncated: node.bytes.length > 65536 });
|
||||
}
|
||||
if (path === "/download" || path === "/read-chunk") {
|
||||
if (
|
||||
url.searchParams.has("expected_revision") &&
|
||||
url.searchParams.get("expected_revision") !== node.entry.revision
|
||||
) return failure("conflict");
|
||||
if (faults.get(id) === "image_error" && node.entry.content_type?.startsWith("image/")) {
|
||||
return failure("storage_unavailable");
|
||||
}
|
||||
const bytes = path === "/read-chunk"
|
||||
? node.bytes.slice(
|
||||
Number(url.searchParams.get("offset")),
|
||||
Number(url.searchParams.get("offset")) + Number(url.searchParams.get("length")),
|
||||
)
|
||||
: node.bytes;
|
||||
return new Response(new Uint8Array(bytes).buffer, {
|
||||
headers: {
|
||||
"content-type": node.entry.content_type!,
|
||||
"content-disposition": "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
return failure("not_found");
|
||||
}
|
||||
return { handler, log };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Synthetic raster bytes only; no fixture CSS, DOM renderer, image URL or UI branch.
|
||||
// A real PNG with 1200×800 natural dimensions and simple colored checker/landscape shapes.
|
||||
import { deflateSync } from "node:zlib";
|
||||
export const fixtureImageWidth = 1200;
|
||||
export const fixtureImageHeight = 800;
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const bytes = new Uint8Array(data.length + 12);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, data.length);
|
||||
bytes.set(new TextEncoder().encode(type), 4);
|
||||
bytes.set(data, 8);
|
||||
view.setUint32(data.length + 8, crc32(bytes.subarray(4, data.length + 8)));
|
||||
return bytes;
|
||||
}
|
||||
const ihdr = new Uint8Array(13);
|
||||
const dimensions = new DataView(ihdr.buffer);
|
||||
dimensions.setUint32(0, fixtureImageWidth);
|
||||
dimensions.setUint32(4, fixtureImageHeight);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2; // 8-bit RGB, no interlacing.
|
||||
const rowLength = fixtureImageWidth * 3 + 1;
|
||||
const pixels = new Uint8Array(rowLength * fixtureImageHeight);
|
||||
for (let y = 0; y < fixtureImageHeight; y++) {
|
||||
for (let x = 0; x < fixtureImageWidth; x++) {
|
||||
const checker = (Math.floor(x / 100) + Math.floor(y / 100)) % 2;
|
||||
let rgb = checker ? [196, 221, 229] : [231, 240, 244];
|
||||
if ((x - 930) ** 2 + (y - 170) ** 2 < 105 ** 2) rgb = [238, 175, 72];
|
||||
const ridge = 480 - 150 * Math.sin(x / 210);
|
||||
if (y > ridge) rgb = checker ? [42, 112, 123] : [52, 135, 141];
|
||||
if (y > 650 + 55 * Math.cos(x / 140)) rgb = checker ? [25, 64, 83] : [34, 82, 100];
|
||||
pixels.set(rgb, y * rowLength + 1 + x * 3);
|
||||
}
|
||||
}
|
||||
const parts = [
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", new Uint8Array(deflateSync(pixels, { level: 9 }))),
|
||||
chunk("IEND", new Uint8Array()),
|
||||
];
|
||||
export const fixturePng = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
fixturePng.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
if (fixturePng.length >= 256 * 1024) throw new Error("Synthetic PNG exceeds raster byte cap");
|
||||
@@ -0,0 +1,115 @@
|
||||
// Serves the real static production shell. Drive is an in-memory fake API;
|
||||
// other routes reuse the shared Home/settings fixtures via a loopback proxy.
|
||||
import { dirname, fromFileUrl, join } from "jsr:@std/path@1.1.4";
|
||||
import { createDriveFixture } from "./api.ts";
|
||||
import { cleanupPlan, workerCatalog, workerSnapshot } from "./workers.ts";
|
||||
const port = Number(Deno.args[0]);
|
||||
const buildRoot = Deno.args[1];
|
||||
if (!Number.isInteger(port) || !buildRoot) {
|
||||
throw new Error("usage: server <port> <production-build-root>");
|
||||
}
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const homePort = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const home = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||
String(homePort),
|
||||
buildRoot,
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
let stopped = false;
|
||||
function cleanup() {
|
||||
if (!stopped) {
|
||||
stopped = true;
|
||||
try {
|
||||
home.kill("SIGTERM");
|
||||
} catch { /* already exited */ }
|
||||
}
|
||||
}
|
||||
Deno.addSignalListener("SIGTERM", () => {
|
||||
cleanup();
|
||||
Deno.exit(0);
|
||||
});
|
||||
Deno.addSignalListener("SIGINT", () => {
|
||||
cleanup();
|
||||
Deno.exit(0);
|
||||
});
|
||||
const fixture = createDriveFixture();
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${homePort}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) {
|
||||
cleanup();
|
||||
throw new Error("shared Home fixture failed to start");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") {
|
||||
return Response.json({ authority: "fixture, not actual Backend", ready: true });
|
||||
}
|
||||
const drive = await fixture.handler(request);
|
||||
if (drive) return drive;
|
||||
const api = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||
if (api?.[2] === "/workers") {
|
||||
if (api[1] === "workers-error") {
|
||||
return Response.json({ error: "Worker fixture unavailable" }, { status: 503 });
|
||||
}
|
||||
return Response.json(workerCatalog(decodeURIComponent(api[1])));
|
||||
}
|
||||
if (api?.[2].match(/^\/runtimes\/[^/]+\/cleanup-plan$/)) {
|
||||
return Response.json(cleanupPlan(decodeURIComponent(api[1]), api[2].split("/")[2]));
|
||||
}
|
||||
// Home fixture keeps the production WebSocket subscription flow intact.
|
||||
if (api?.[2] === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: frame.message.params.request_id,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot: {
|
||||
topic: "workers",
|
||||
data: { workers: workerSnapshot(decodeURIComponent(api[1])) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
return response;
|
||||
}
|
||||
const proxy = new URL(url.pathname + url.search, `http://127.0.0.1:${homePort}`);
|
||||
const response = await fetch(proxy, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.method === "GET" || request.method === "HEAD"
|
||||
? undefined
|
||||
: await request.arrayBuffer(),
|
||||
redirect: "manual",
|
||||
});
|
||||
if (api?.[2] === "/workspace") {
|
||||
const body = await response.json();
|
||||
body.display_name ??= `Drive ${decodeURIComponent(api[1])}`;
|
||||
return Response.json(body, { status: response.status });
|
||||
}
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// One synthetic authoritative Worker catalog. Both REST and protocol projections
|
||||
// derive from these same records; no grant-only Worker list or production override.
|
||||
import type { WorkerListResponse } from "../../../web/workspace/src/lib/generated/worker-launch-api.ts";
|
||||
import type { SubscriptionWorker } from "../../../web/workspace/src/lib/generated/protocol.ts";
|
||||
import type { RuntimeCleanupPlanResponse } from "../../../web/workspace/src/lib/generated/runtime-api.ts";
|
||||
export function workerCatalog(workspaceId: string): WorkerListResponse {
|
||||
const representative = ["home-owner", "home-member", "home-long", "home-error"].includes(
|
||||
workspaceId,
|
||||
);
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
items: representative
|
||||
? ["a", "b"].map((suffix, index) => ({
|
||||
runtime_id: workspaceId === "home-long"
|
||||
? `fixture-runtime-${"r".repeat(100)}`
|
||||
: "fixture-runtime",
|
||||
worker_id: workspaceId === "home-long"
|
||||
? `fixture-worker-${suffix}-${"w".repeat(100)}`
|
||||
: `fixture-worker-${suffix}`,
|
||||
resource_key: `W-${801 + index}`,
|
||||
host_id: "fixture-host",
|
||||
display_name: workspaceId === "home-long"
|
||||
? "Documentation and distributed development Worker — 国際化されたドキュメントを整理する長い表示名 — "
|
||||
.repeat(2)
|
||||
: index === 0
|
||||
? "Documentation Worker"
|
||||
: "画像・資料を整理する Worker — 長い名前",
|
||||
label: `Fixture Worker ${suffix}`,
|
||||
profile: "default",
|
||||
workspace: { workspace_id: workspaceId, visibility: "workspace", identity: workspaceId },
|
||||
availability: "observed",
|
||||
state: "idle",
|
||||
worker_state: { last_command_id: 0, state: { kind: "idle" } },
|
||||
pinned: false,
|
||||
retention_state: "active",
|
||||
implementation: { kind: "builtin", display_hint: "fixture" },
|
||||
tags: [],
|
||||
diagnostics: [],
|
||||
workdir_attachments: [],
|
||||
last_seen_at: "2026-01-02T00:00:00Z",
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
export function workerSnapshot(workspaceId: string): SubscriptionWorker[] {
|
||||
return workerCatalog(workspaceId).items.map((worker) => ({
|
||||
worker_id: worker.worker_id,
|
||||
runtime_id: worker.runtime_id,
|
||||
resource_key: worker.resource_key,
|
||||
availability: worker.availability,
|
||||
state: "idle",
|
||||
worker_state: worker.worker_state,
|
||||
workspace_id: workspaceId,
|
||||
display_name: worker.display_name,
|
||||
profile: worker.profile,
|
||||
has_running_internal_workers: false,
|
||||
workdir_attachments: [],
|
||||
}));
|
||||
}
|
||||
export function cleanupPlan(workspaceId: string, runtimeId: string): RuntimeCleanupPlanResponse {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
diagnostics: [],
|
||||
digest: "fixture-cleanup",
|
||||
generated_at: "2026-01-02T00:00:00Z",
|
||||
revision: "1",
|
||||
workers: [],
|
||||
workdirs: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Development-only visual evidence over the real Workers route and loopback
|
||||
// HTTP fixtures. No Backend/Runtime/account/grant state is mutated.
|
||||
// Run from root: deno run --config tools/web-ux/deno.json -A tools/web-ux/drive-grants/visual-review.ts after
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
const root = new URL("../../../", import.meta.url).pathname;
|
||||
const run = Deno.args[0] ?? "after";
|
||||
const before = run === "before";
|
||||
const out = `${root}target/web-ux/t724-drive-grants/${run}`;
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "vite",
|
||||
command: "deno",
|
||||
args: ["task", "dev", "--host", "127.0.0.1", "--port", String(port)],
|
||||
cwd: `${root}web/workspace`,
|
||||
readyUrl: baseUrl,
|
||||
}],
|
||||
import.meta.url,
|
||||
`${out}/logs`,
|
||||
[],
|
||||
);
|
||||
const worker = {
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker-a",
|
||||
resource_key: "W-1",
|
||||
host_id: "host-a",
|
||||
display_name: "Documentation Worker",
|
||||
label: "Worker",
|
||||
profile: "builtin:coder",
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: "space",
|
||||
workspace_id: "space",
|
||||
},
|
||||
availability: "observed",
|
||||
state: "stopped",
|
||||
pinned: false,
|
||||
retention_state: "normal",
|
||||
implementation: { kind: "runtime", display_hint: "Runtime Worker" },
|
||||
workdir_attachments: [],
|
||||
diagnostics: [],
|
||||
restore_observation_token: "observation-1",
|
||||
};
|
||||
const grant = {
|
||||
grant_id: "1",
|
||||
workspace_id: "space",
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker-a",
|
||||
access: "read_only",
|
||||
revoked: false,
|
||||
created_by: "fixture-owner",
|
||||
created_at: "2026-10-09",
|
||||
revoked_by: null,
|
||||
revoked_at: null,
|
||||
};
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const longWorker = {
|
||||
...worker,
|
||||
runtime_id: `runtime-${"r".repeat(140)}`,
|
||||
worker_id: `worker-${"w".repeat(140)}`,
|
||||
display_name:
|
||||
"Documentation and distributed development Worker with a long display name for international documentation review",
|
||||
};
|
||||
const captures: unknown[] = [];
|
||||
try {
|
||||
for (const persona of ["owner", "non-owner"]) {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("requestfailed", (request) =>
|
||||
errors.push(
|
||||
`${request.method()} ${
|
||||
new URL(request.url()).pathname
|
||||
}: ${request.failure()?.errorText}`,
|
||||
));
|
||||
let state = "active";
|
||||
await page.routeWebSocket("**/api/w/space/protocol/ws", () => {});
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url()), path = url.pathname;
|
||||
if (!path.startsWith("/api/")) {
|
||||
return await route.continue();
|
||||
}
|
||||
const owner = persona === "owner";
|
||||
let body: unknown;
|
||||
if (path === "/api/workspaces") {
|
||||
body = [{
|
||||
workspace_id: "space",
|
||||
display_name: "Drive review",
|
||||
owner_account_id: "fixture-owner",
|
||||
state: "active",
|
||||
created_at: "2026-01-01",
|
||||
updated_at: "2026-01-01",
|
||||
}];
|
||||
} else if (path.endsWith("/workspace")) {
|
||||
body = {
|
||||
workspace_id: "space",
|
||||
display_name: "Drive review",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: baseUrl,
|
||||
public_base_url: baseUrl,
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: owner,
|
||||
manage_secrets: owner,
|
||||
manage_runtimes: owner,
|
||||
delete_workspace: owner,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: {
|
||||
status: "ready",
|
||||
note: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
host_worker_bridge: {
|
||||
status: "ready",
|
||||
note: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
companion_console: {
|
||||
status: "ready",
|
||||
note: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (path.endsWith("/workers")) {
|
||||
body = {
|
||||
workspace_id: "space",
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
items: [state === "long" ? longWorker : worker],
|
||||
};
|
||||
} else if (path.endsWith("/repositories")) {
|
||||
body = {
|
||||
workspace_id: "space",
|
||||
items: [],
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
};
|
||||
} else if (path.endsWith("/cleanup-plan")) {
|
||||
body = {
|
||||
workspace_id: "space",
|
||||
generated_at: "2026-10-09",
|
||||
runtime_id: "runtime-a",
|
||||
revision: "plan-1",
|
||||
digest: "digest-1",
|
||||
diagnostics: [],
|
||||
workdirs: [],
|
||||
workers: [],
|
||||
};
|
||||
} else if (path.endsWith("/drive/grants")) {
|
||||
if (state === "loading") {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
if (state === "error") {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
json: {
|
||||
code: "storage_unavailable",
|
||||
classification: "not_committed",
|
||||
message: `Drive storage unavailable. ${
|
||||
"A long diagnostic requiring readable wrapping and an explicit refresh action. "
|
||||
.repeat(12)
|
||||
}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
body = {
|
||||
grants: state === "empty" ? [] : [{
|
||||
...grant,
|
||||
runtime_id: state === "long" ? longWorker.runtime_id : worker.runtime_id,
|
||||
worker_id: state === "long" ? longWorker.worker_id : worker.worker_id,
|
||||
}],
|
||||
next_after: null,
|
||||
};
|
||||
} else if (path.endsWith("/working-directories")) {
|
||||
body = { workspace_id: "space", items: [], diagnostics: [] };
|
||||
} else {
|
||||
errors.push(`Unmatched fixture ${path}`);
|
||||
body = {};
|
||||
}
|
||||
await route.fulfill({ json: body });
|
||||
});
|
||||
let release: (() => void) | undefined;
|
||||
const response = await page.goto(`${baseUrl}/w/space/workers`);
|
||||
await page.getByRole("heading", { name: "Workers", exact: true })
|
||||
.waitFor().catch(async (cause) => {
|
||||
console.log(errors, await page.locator("body").innerText());
|
||||
throw cause;
|
||||
});
|
||||
await page.getByText("Documentation Worker", { exact: true }).first()
|
||||
.waitFor().catch(async (cause) => {
|
||||
console.log(errors, await page.locator("body").innerText());
|
||||
throw cause;
|
||||
});
|
||||
async function capture(name: string) {
|
||||
await Deno.mkdir(out, { recursive: true });
|
||||
const filename = `${persona}-${theme}-${width}-${name}.png`;
|
||||
const screenshot = await page.screenshot({
|
||||
path: `${out}/${filename}`,
|
||||
fullPage: true,
|
||||
});
|
||||
if (!before && persona === "owner") {
|
||||
const clipped = await page.locator(".drive-grants").evaluate((
|
||||
element,
|
||||
) =>
|
||||
[...element.querySelectorAll("button, select, summary")].some(
|
||||
(control) => {
|
||||
const box = control.getBoundingClientRect();
|
||||
return box.left < 0 ||
|
||||
box.right > element.ownerDocument.defaultView!.innerWidth;
|
||||
},
|
||||
)
|
||||
);
|
||||
if (clipped) {
|
||||
throw new Error(
|
||||
`Grant control clipped: ${persona} ${theme} ${width} ${name}`,
|
||||
);
|
||||
}
|
||||
await page.locator(".drive-grants").screenshot({
|
||||
path: `${out}/grants-${filename}`,
|
||||
});
|
||||
}
|
||||
captures.push({
|
||||
persona,
|
||||
theme,
|
||||
width,
|
||||
state: name,
|
||||
path: filename,
|
||||
sha256: await sha256(screenshot),
|
||||
documentStatus: response?.status(),
|
||||
errors: [...errors],
|
||||
visibleErrors: await page.locator("[role=alert]")
|
||||
.allTextContents(),
|
||||
overflow: await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
accessibility: await page.locator("main").ariaSnapshot(),
|
||||
});
|
||||
}
|
||||
if (before || !ownerPersona(persona)) await capture("workers");
|
||||
else {
|
||||
await page.getByRole("combobox", { name: "Worker", exact: true })
|
||||
.selectOption(JSON.stringify(["runtime-a", "worker-a"]));
|
||||
await page.getByRole("button", { name: "Revoke Drive grant 1" })
|
||||
.waitFor();
|
||||
await capture("active");
|
||||
state = "empty";
|
||||
await page.getByRole("button", { name: "Refresh grants" }).click();
|
||||
await page.getByText("No active Drive grants for this Worker.")
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Add Drive grant" }).click();
|
||||
await capture("empty-form");
|
||||
state = "error";
|
||||
await page.getByRole("button", { name: "Refresh grants" }).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
await capture("error");
|
||||
state = "loading";
|
||||
await page.getByRole("button", { name: "Refresh grants" }).click();
|
||||
await page.getByText("Loading all grant pages…").waitFor();
|
||||
await capture("loading");
|
||||
release?.();
|
||||
}
|
||||
state = "long";
|
||||
await page.reload();
|
||||
await page.getByText(longWorker.display_name, { exact: true }).first()
|
||||
.waitFor();
|
||||
if (!before && persona === "owner") {
|
||||
await page.getByRole("combobox", { name: "Worker", exact: true })
|
||||
.selectOption(
|
||||
JSON.stringify([longWorker.runtime_id, longWorker.worker_id]),
|
||||
);
|
||||
await page.getByRole("button", { name: "Revoke Drive grant 1" })
|
||||
.waitFor();
|
||||
await page.getByText("Worker identity", { exact: true }).click();
|
||||
}
|
||||
await capture("long-values");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const pageSource = await Deno.readFile(
|
||||
`${root}web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte`,
|
||||
);
|
||||
const componentSource = await Deno.readFile(
|
||||
`${root}web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.svelte`,
|
||||
);
|
||||
const apiSource = await Deno.readFile(
|
||||
`${root}web/workspace/src/lib/workspace/drive-grants/api.ts`,
|
||||
);
|
||||
await Deno.writeTextFile(
|
||||
`${out}/review-context.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario: "real Workers route, loopback authoritative API projections",
|
||||
run,
|
||||
browserVersion: browser.version(),
|
||||
workersPageSha256: await sha256(pageSource),
|
||||
componentSha256: await sha256(componentSource),
|
||||
apiSha256: await sha256(apiSource),
|
||||
captures,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`Visual evidence: ${out}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
function ownerPersona(persona: string) {
|
||||
return persona === "owner";
|
||||
}
|
||||
async function sha256(bytes: Uint8Array) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer))]
|
||||
.map((
|
||||
byte,
|
||||
) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -6,9 +6,11 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
|
||||
"build": "deno run -A npm:vite@8.3.2 build",
|
||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Worker Drive grant controls
|
||||
|
||||
`WorkerDriveGrants.svelte` is embedded in the existing Workers page. The page supplies its current `workspaceWorkersStore().catalogWorkers` projection; this component neither discovers Workers nor maintains a registry.
|
||||
|
||||
## Integration interface
|
||||
|
||||
```ts
|
||||
{
|
||||
workspaceId: string;
|
||||
workers: Pick<Worker, "runtime_id" | "worker_id" | "display_name" | "label">[];
|
||||
canManage: boolean;
|
||||
workersReady?: boolean; // defaults to true for standalone use
|
||||
}
|
||||
```
|
||||
|
||||
The page maps `canManage` to `workspace.permissions.manage_runtimes` (the current Backend owner settings permission) and `workersReady` to `catalogReady && !catalogRefreshing`. Non-owners have no grant surface or grant requests. Backend owner authorization remains authoritative on every request. Profile Drive tool enablement is separate and is not edited here.
|
||||
|
||||
`api.ts` is the owner-controlled grant transport adapter using generated `drive-api.ts` DTOs. It keeps grant administration separate from ordinary content access:
|
||||
|
||||
- `listDriveGrants(workspaceId): Promise<DriveGrantResponse[]>` — fetch **all** grant pages (200/page, bounded to 100 pages); reject incomplete, foreign-Workspace, duplicate, unordered or cyclic results instead of silently truncating.
|
||||
- `createDriveGrant(workspaceId, DriveGrantCreateRequest): Promise<DriveGrantResponse>`.
|
||||
- `revokeDriveGrant(workspaceId, DriveGrantResponse): Promise<DriveGrantResponse>`.
|
||||
- `DriveGrantError` with `outcome: "not_committed" | "unknown"`.
|
||||
- Generated `DriveAccess` / `DriveGrantResponse` type exports.
|
||||
|
||||
Mutation success must validate Workspace/Runtime/Worker identity and the requested resulting state. HTTP bodies are bounded; transport loss, malformed success and unclassified failures are unknown mutation outcomes. No Worker credentials or transport identity headers are sent; requests use current browser same-origin authentication and bypass caching.
|
||||
|
||||
Workspace, selected Worker, catalog membership/readiness, permission and component lifetime fence asynchronous results. Workspace switches clear selection; unchanged catalog identities do not clear pending mutations. Active grants match **both** Runtime and Worker IDs. An existing active grant must be revoked before another access level is created. Unknown outcomes remove actionable state until an explicit authority refresh, never automatic mutation retry.
|
||||
|
||||
## Narrow validation
|
||||
|
||||
From `web/workspace`:
|
||||
|
||||
```sh
|
||||
deno task test:component src/lib/workspace/drive-grants/WorkerDriveGrants.browser.spec.ts 'src/routes/w/[workspaceId]/workers/workers-page.browser.spec.ts' 'src/routes/w/[workspaceId]/workers/workers-restore-integration.browser.spec.ts'
|
||||
deno run -A npm:svelte-check@4.7.6 --tsconfig ./src/lib/workspace/drive-grants/tsconfig.check.json
|
||||
# Full integration check (also includes sibling-owned work):
|
||||
deno task check
|
||||
```
|
||||
|
||||
Recorded result: 21 grant tests plus 29 existing Workers tests passed. Final parent integration `deno task check` reported zero errors/warnings, and the complete component suite passed 301 tests in 32 files. Intermediate test-only `ByRoleOptions` errors were corrected before final validation.
|
||||
|
||||
The component tests exercise actual rendered controls with HTTP-boundary fixtures, including pagination, same Worker ID on different Runtimes, owner gating, scope switches, removal, single-submit behavior, read-only/read-write creation, revocation, denial and unknown outcomes. The existing Workers tests cover retention/restore/catalog regressions.
|
||||
|
||||
## Visual evidence
|
||||
|
||||
The development-only `tools/web-ux/drive-grants/visual-review.ts` runner uses the real Workers route and application shell with loopback browser API fixtures. It creates no real Backend, Runtime, Worker, account or grant state. It lives outside production Web source and is independently Deno-checked:
|
||||
|
||||
```sh
|
||||
# From repository root, in the dev shell:
|
||||
deno run --check --config tools/web-ux/deno.json -A tools/web-ux/drive-grants/visual-review.ts after
|
||||
```
|
||||
|
||||
`before` mode must run against the original Workers page source. For the recorded baseline, the original page was temporarily restored from the read-only Git source and the implemented page restored in `finally`; Git/index/history were not changed.
|
||||
|
||||
Evidence: `target/web-ux/t724-drive-grants/{before,after}/review-context.json` and sibling PNGs. Contexts record screenshot hashes, Workers page source hash, browser version, accessibility, visible/browser/request errors and document overflow. The after context also records component/API source hashes and the runner checks grant controls for viewport clipping.
|
||||
|
||||
Reviewed owner/non-owner, light/dark, 1440/768/390 px; active, empty/create form, loading, deliberately failed list, long labels/identifiers/errors. The before bundle has 24 captures and the after bundle has 42. `compare-{theme}-{width}.png` and `states-{theme}-{width}.png` contact sheets in the same artifact root were opened by the implementer, along with individual captures.
|
||||
|
||||
Finding fixed: the pre-existing Worker table widened the page's grid track and clipped new grant controls. The page now constrains its grid and gives the table a named keyboard-accessible local scroll region. Long native-select labels have a full identity disclosure. No unexpected browser errors or document overflow were observed; intentional HTTP 503 console errors belong to the error fixture (and remain in the cumulative diagnostics of its subsequent loading capture). Other pre-existing Worker table presentation is unchanged.
|
||||
@@ -0,0 +1,471 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { tick } from "svelte";
|
||||
import WorkerDriveGrants from "./WorkerDriveGrants.svelte";
|
||||
import {
|
||||
createDriveGrant,
|
||||
listDriveGrants,
|
||||
parseDriveGrantListResponse,
|
||||
} from "./api.ts";
|
||||
import type { DriveGrantResponse } from "#lib/generated/drive-api.ts";
|
||||
|
||||
const workers = [
|
||||
{
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker",
|
||||
display_name: "Alpha",
|
||||
label: "Alpha",
|
||||
},
|
||||
{
|
||||
runtime_id: "runtime-b",
|
||||
worker_id: "worker",
|
||||
display_name: "Beta",
|
||||
label: "Beta",
|
||||
},
|
||||
];
|
||||
function grant(
|
||||
overrides: Partial<DriveGrantResponse> = {},
|
||||
): DriveGrantResponse {
|
||||
return {
|
||||
grant_id: "1",
|
||||
workspace_id: "space",
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker",
|
||||
access: "read_only",
|
||||
revoked: false,
|
||||
created_by: "owner",
|
||||
created_at: "now",
|
||||
revoked_by: null,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const page = (
|
||||
grants: DriveGrantResponse[] = [],
|
||||
next_after: string | null = null,
|
||||
) => Response.json({ grants, next_after });
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function select(index = 0) {
|
||||
const combo = screen.getByRole("combobox", {
|
||||
name: "Worker",
|
||||
}) as HTMLSelectElement;
|
||||
await fireEvent.change(combo, {
|
||||
target: {
|
||||
value: JSON.stringify([
|
||||
workers[index].runtime_id,
|
||||
workers[index].worker_id,
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(combo.value).toBe(
|
||||
JSON.stringify([workers[index].runtime_id, workers[index].worker_id]),
|
||||
);
|
||||
}
|
||||
const props = { workspaceId: "space", workers, canManage: true };
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("non-owner has no grant surface and makes no grant requests", async () => {
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(WorkerDriveGrants, { ...props, canManage: false });
|
||||
await tick();
|
||||
expect(screen.queryByRole("region", { name: "Worker Drive grants" }))
|
||||
.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("catalog readiness gates selection and removed Workers cannot retain grants", async () => {
|
||||
const fetch = vi.fn(async () => page([grant()]));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const view = render(WorkerDriveGrants, { ...props, workersReady: false });
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
await view.rerender({ workersReady: true });
|
||||
await select();
|
||||
expect(await screen.findByRole("button", { name: "Revoke Drive grant 1" }))
|
||||
.toBeTruthy();
|
||||
await view.rerender({ workers: [workers[1]] });
|
||||
expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("combobox", { name: "Worker" }) as HTMLSelectElement)
|
||||
.value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("all pages are loaded and active grants match both runtime and Worker identities", async () => {
|
||||
const second = deferred<Response>();
|
||||
const fetch = vi.fn().mockResolvedValueOnce(
|
||||
page([grant({ runtime_id: "runtime-b" })], "1"),
|
||||
).mockReturnValueOnce(second.promise);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"/api/w/space/drive/grants?limit=200&after=1",
|
||||
);
|
||||
expect(screen.queryByText("No active Drive grants for this Worker."))
|
||||
.toBeNull();
|
||||
second.resolve(
|
||||
page([
|
||||
grant({ grant_id: "2" }),
|
||||
grant({ grant_id: "3", worker_id: "another" }),
|
||||
grant({ grant_id: "4", revoked: true }),
|
||||
]),
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: "Revoke Drive grant 2" }))
|
||||
.toBeTruthy();
|
||||
expect(screen.getAllByRole("button", { name: /Revoke/ })).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "Add Drive grant" })).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["read_only", "read_write"] as const)(
|
||||
"create %s and revoke use current Backend authentication and confirmed state",
|
||||
async (access) => {
|
||||
const created = grant({ access });
|
||||
const fetch = vi.fn().mockResolvedValueOnce(page()).mockResolvedValueOnce(
|
||||
Response.json(created),
|
||||
).mockResolvedValueOnce(page([created])).mockResolvedValueOnce(
|
||||
Response.json({
|
||||
...created,
|
||||
revoked: true,
|
||||
revoked_at: "now",
|
||||
revoked_by: "owner",
|
||||
}),
|
||||
).mockResolvedValueOnce(page());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.change(screen.getByRole("combobox", { name: "Access" }), {
|
||||
target: { value: access },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
const revoke = await screen.findByRole("button", {
|
||||
name: "Revoke Drive grant 1",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect((revoke as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker",
|
||||
access,
|
||||
});
|
||||
expect(fetch.mock.calls[1][1].credentials).toBe("same-origin");
|
||||
expect(fetch.mock.calls[1][1].headers).toEqual({
|
||||
"content-type": "application/json",
|
||||
});
|
||||
await fireEvent.click(revoke);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull()
|
||||
);
|
||||
expect(fetch.mock.calls[3][0]).toBe("/api/w/space/drive/grants/1");
|
||||
expect(fetch.mock.calls[3][1].method).toBe("DELETE");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["typed unknown", "transport loss", "malformed success"])(
|
||||
"%s blocks mutation retry and offers only an explicit authority refresh",
|
||||
async (kind) => {
|
||||
const fetch = vi.fn().mockResolvedValueOnce(page());
|
||||
if (kind === "transport loss") {
|
||||
fetch.mockRejectedValueOnce(new Error("lost"));
|
||||
} else {fetch.mockResolvedValueOnce(
|
||||
kind === "typed unknown"
|
||||
? Response.json({
|
||||
code: "outcome_unknown",
|
||||
classification: "unknown",
|
||||
message: "unknown",
|
||||
}, { status: 503 })
|
||||
: Response.json({}),
|
||||
);}
|
||||
fetch.mockResolvedValueOnce(page([grant()]));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"outcome unknown",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Create Drive grant" }))
|
||||
.toBeNull();
|
||||
expect(screen.queryByText("No active Drive grants for this Worker."))
|
||||
.toBeNull();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Refresh grants" }),
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: "Revoke Drive grant 1" }))
|
||||
.toBeTruthy();
|
||||
expect(fetch.mock.calls.filter((call) => call[1]?.method === "POST"))
|
||||
.toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("typed denial is not reported as an unknown or successful mutation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(page()).mockResolvedValueOnce(
|
||||
Response.json({
|
||||
code: "denied",
|
||||
classification: "not_committed",
|
||||
message: "Drive access denied",
|
||||
}, { status: 403 }),
|
||||
),
|
||||
);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"Drive access denied",
|
||||
);
|
||||
expect(screen.queryByText("Drive grant created.")).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["Worker", "Workspace", "permission", "removed Worker"])(
|
||||
"late mutation response cannot affect a changed %s scope",
|
||||
async (change) => {
|
||||
const pending = deferred<Response>();
|
||||
const fetch = vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce(
|
||||
pending.promise,
|
||||
).mockResolvedValue(page());
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const view = render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
if (change === "Worker") await select(1);
|
||||
if (change === "Workspace") await view.rerender({ workspaceId: "other" });
|
||||
if (change === "permission") await view.rerender({ canManage: false });
|
||||
if (change === "removed Worker") {
|
||||
await view.rerender({
|
||||
workers: [workers[1]],
|
||||
});
|
||||
}
|
||||
pending.resolve(Response.json(grant()));
|
||||
await pending.promise;
|
||||
await tick();
|
||||
await tick();
|
||||
expect(screen.queryByText("Drive grant created.")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Revoke Drive grant 1" }))
|
||||
.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("late list response cannot replace the selected Worker state", async () => {
|
||||
const pending = deferred<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValueOnce(
|
||||
page([grant({ runtime_id: "runtime-b", grant_id: "2" })]),
|
||||
),
|
||||
);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await select(1);
|
||||
expect(await screen.findByRole("button", { name: "Revoke Drive grant 2" }))
|
||||
.toBeTruthy();
|
||||
pending.resolve(page([grant()]));
|
||||
await pending.promise;
|
||||
await tick();
|
||||
await tick();
|
||||
expect(screen.queryByRole("button", { name: "Revoke Drive grant 1" }))
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Revoke Drive grant 2" }))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test("unchanged catalog identities do not clear an in-flight mutation", async () => {
|
||||
const pending = deferred<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValueOnce(page([grant()])),
|
||||
);
|
||||
const view = render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
await view.rerender({
|
||||
workers: workers.map((worker) => ({
|
||||
...worker,
|
||||
display_name: `${worker.label} updated`,
|
||||
})),
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
pending.resolve(Response.json(grant()));
|
||||
expect(await screen.findByText("Drive grant created.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("pending create accepts only one submission", async () => {
|
||||
const pending = deferred<Response>();
|
||||
const fetch = vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce(
|
||||
pending.promise,
|
||||
).mockResolvedValueOnce(page([grant()]));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Create Drive grant" });
|
||||
await fireEvent.click(button);
|
||||
await fireEvent.submit(button.closest("form")!);
|
||||
expect(fetch.mock.calls.filter((call) => call[1]?.method === "POST"))
|
||||
.toHaveLength(1);
|
||||
pending.resolve(Response.json(grant()));
|
||||
expect(await screen.findByText("Drive grant created.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an uncertain revoke cannot claim removal or permit a blind retry", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(page([grant()])).mockRejectedValueOnce(
|
||||
new Error("transport lost"),
|
||||
),
|
||||
);
|
||||
render(WorkerDriveGrants, props);
|
||||
await select();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Revoke Drive grant 1" }),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"outcome unknown",
|
||||
);
|
||||
expect(screen.queryByText("Drive grant revoked.")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull();
|
||||
});
|
||||
|
||||
test("complete Backend Worker identifiers are available in a disclosure", async () => {
|
||||
const item = {
|
||||
...workers[0],
|
||||
runtime_id: `runtime-${"r".repeat(140)}`,
|
||||
worker_id: `worker-${"w".repeat(140)}`,
|
||||
display_name: "Long Worker name ".repeat(12),
|
||||
};
|
||||
vi.stubGlobal("fetch", vi.fn(async () => page()));
|
||||
render(WorkerDriveGrants, { ...props, workers: [item] });
|
||||
await fireEvent.change(screen.getByRole("combobox", { name: "Worker" }), {
|
||||
target: { value: JSON.stringify([item.runtime_id, item.worker_id]) },
|
||||
});
|
||||
await screen.findByRole("button", { name: "Add Drive grant" });
|
||||
const identity = screen.getByText("Worker identity").closest("details");
|
||||
expect(screen.getByText(item.runtime_id).closest("details")).toBe(identity);
|
||||
expect(screen.getByText(item.worker_id).closest("details")).toBe(identity);
|
||||
});
|
||||
|
||||
test("parser rejects malformed access, missing revocation state and noncanonical grant cursors", () => {
|
||||
for (
|
||||
const value of [
|
||||
{ grants: [grant({ access: "admin" as never })], next_after: null },
|
||||
{ grants: [{ ...grant(), revoked: undefined }], next_after: null },
|
||||
{ grants: [], next_after: "01" },
|
||||
]
|
||||
) expect(() => parseDriveGrantListResponse(value)).toThrow();
|
||||
});
|
||||
|
||||
test("foreign Workspace pages and cyclic cursors never become a complete grant list", async () => {
|
||||
for (
|
||||
const result of [page([grant({ workspace_id: "other" })]), page([], "1")]
|
||||
) {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => result));
|
||||
await expect(listDriveGrants("space")).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("successful response with wrong Worker identity is an unknown mutation outcome", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json(grant({ runtime_id: "runtime-b" }))),
|
||||
);
|
||||
await expect(
|
||||
createDriveGrant("space", {
|
||||
runtime_id: "runtime-a",
|
||||
worker_id: "worker",
|
||||
access: "read_only",
|
||||
}),
|
||||
).rejects.toMatchObject({ outcome: "unknown" });
|
||||
});
|
||||
|
||||
test("Catalog readiness refresh does not unlock or abandon a pending grant on the same Worker", async () => {
|
||||
const pending = deferred<Response>();
|
||||
let committed = false;
|
||||
const fetch = vi.fn((_: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "POST") return pending.promise;
|
||||
return Promise.resolve(page(committed ? [grant()] : []));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const view = render(WorkerDriveGrants, { ...props, workersReady: true });
|
||||
await select();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Add Drive grant" }))
|
||||
.toBeDefined()
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add Drive grant" }),
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create Drive grant" }),
|
||||
);
|
||||
await view.rerender({ ...props, workersReady: false });
|
||||
await view.rerender({ ...props, workersReady: true });
|
||||
await tick();
|
||||
const add = screen.queryByRole("button", { name: "Add Drive grant" }) as
|
||||
| HTMLButtonElement
|
||||
| null;
|
||||
if (add) expect(add.disabled).toBe(true);
|
||||
const saving = screen.getByRole("button", {
|
||||
name: "Saving…",
|
||||
}) as HTMLButtonElement;
|
||||
expect(saving.disabled).toBe(true);
|
||||
committed = true;
|
||||
pending.resolve(Response.json(grant()));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Revoke Drive grant 1" }))
|
||||
.toBeDefined()
|
||||
);
|
||||
expect(fetch.mock.calls.filter(([, init]) => init?.method === "POST"))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { Worker } from '#lib/workspace/sidebar/types.ts';
|
||||
import { createDriveGrant, DriveGrantError, listDriveGrants, revokeDriveGrant, type DriveAccess, type DriveGrantResponse } from './api.ts';
|
||||
|
||||
export type GrantWorker = Pick<Worker, 'runtime_id' | 'worker_id' | 'display_name' | 'label'>;
|
||||
let { workspaceId, workers, canManage, workersReady = true }: {
|
||||
workspaceId: string; workers: GrantWorker[]; canManage: boolean; workersReady?: boolean;
|
||||
} = $props();
|
||||
let selected = $state('');
|
||||
let access = $state<DriveAccess>('read_only');
|
||||
let grants = $state<DriveGrantResponse[]>([]);
|
||||
let loading = $state(false);
|
||||
let ready = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let unresolved = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let generation = 0;
|
||||
let lastWorkspaceId: string | undefined;
|
||||
const key = (worker: GrantWorker) => JSON.stringify([worker.runtime_id, worker.worker_id]);
|
||||
const selectedWorker = $derived(workers.find((worker) => key(worker) === selected));
|
||||
const workerPresent = $derived(workers.some((worker) => key(worker) === selected));
|
||||
const identityScope = $derived(JSON.stringify([workspaceId, canManage, selected, workerPresent]));
|
||||
const active = $derived(selectedWorker ? grants.filter((grant) => !grant.revoked && grant.workspace_id === workspaceId && grant.runtime_id === selectedWorker.runtime_id && grant.worker_id === selectedWorker.worker_id) : []);
|
||||
|
||||
function scope() {
|
||||
const id = workspaceId, identity = selected, epoch = generation;
|
||||
return { id, current: () => generation === epoch && workspaceId === id && selected === identity && canManage && workers.some((worker) => key(worker) === identity) };
|
||||
}
|
||||
// Workspace, Worker membership and permission define identity. Catalog readiness
|
||||
// only gates new actions: it must not abandon a transmitted mutation on the same Worker.
|
||||
$effect(() => {
|
||||
identityScope;
|
||||
untrack(() => {
|
||||
generation++;
|
||||
grants = []; ready = false; busy = false; loading = false;
|
||||
error = null; notice = null; unresolved = false; showCreate = false; access = 'read_only';
|
||||
if (lastWorkspaceId !== workspaceId) { lastWorkspaceId = workspaceId; selected = ''; }
|
||||
if (!workerPresent && selected) selected = '';
|
||||
if (workspaceId && canManage && workersReady && workerPresent) void refresh();
|
||||
});
|
||||
return () => { generation++; };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const usable = workersReady;
|
||||
identityScope;
|
||||
untrack(() => {
|
||||
if (usable && canManage && workerPresent && !busy && !loading && !ready && !unresolved) void refresh();
|
||||
});
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (!canManage || !workersReady || !selectedWorker || busy || loading) return;
|
||||
const request = scope();
|
||||
loading = true; ready = false; error = null;
|
||||
try {
|
||||
const result = await listDriveGrants(request.id);
|
||||
if (!request.current()) return;
|
||||
grants = result; ready = true; unresolved = false;
|
||||
} catch (cause) {
|
||||
if (request.current()) error = cause instanceof Error ? cause.message : 'Drive grants could not be loaded.';
|
||||
} finally { if (request.current()) loading = false; }
|
||||
}
|
||||
async function mutate(grant?: DriveGrantResponse) {
|
||||
const worker = selectedWorker;
|
||||
if (!worker || !canManage || !workersReady || !ready || loading || busy || unresolved) return;
|
||||
if (!grant && active.length > 0) return;
|
||||
if (grant && !active.some((item) => item.grant_id === grant.grant_id)) return;
|
||||
const request = scope();
|
||||
const requestedAccess = access;
|
||||
busy = true; error = null; notice = null;
|
||||
try {
|
||||
const result = grant
|
||||
? await revokeDriveGrant(request.id, grant)
|
||||
: await createDriveGrant(request.id, { runtime_id: worker.runtime_id, worker_id: worker.worker_id, access: requestedAccess });
|
||||
if (!request.current()) return;
|
||||
grants = [...grants.filter((item) => item.grant_id !== result.grant_id), result];
|
||||
notice = grant ? 'Drive grant revoked.' : 'Drive grant created.';
|
||||
showCreate = false;
|
||||
// Re-read current authority, rather than treating the mutation response as a lease.
|
||||
busy = false; ready = false;
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
if (!request.current()) return;
|
||||
unresolved = !(cause instanceof DriveGrantError) || cause.outcome === 'unknown';
|
||||
if (unresolved) ready = false;
|
||||
error = unresolved
|
||||
? 'Drive grant outcome unknown. Refresh grants before deciding on another action; do not blindly retry.'
|
||||
: cause instanceof Error ? cause.message : 'Drive grant request failed.';
|
||||
} finally { if (request.current()) busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if canManage}
|
||||
<section class="drive-grants" aria-label="Worker Drive grants">
|
||||
<header><h2>Drive grants</h2></header>
|
||||
<p class="hint">Grants allow access to this Workspace’s Drive. Drive tool enablement in the Worker’s Profile is separate.</p>
|
||||
{#if !workersReady}
|
||||
<p role="status">Waiting for the current Worker catalog…</p>
|
||||
{:else if workers.length === 0}
|
||||
<p>No Workers are available for a Drive grant.</p>
|
||||
{:else}
|
||||
<label class="worker-select">Worker
|
||||
<select value={selected} onchange={(event) => { selected = event.currentTarget.value; }}>
|
||||
<option value="">Select a Worker</option>
|
||||
{#each workers as worker (key(worker))}
|
||||
<option value={key(worker)}>{worker.display_name || worker.label} · {worker.runtime_id} / {worker.worker_id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if selectedWorker}
|
||||
{#key identityScope}
|
||||
<details class="identity">
|
||||
<summary>Worker identity</summary>
|
||||
<dl>
|
||||
<dt>Runtime</dt><dd><code>{selectedWorker.runtime_id}</code></dd>
|
||||
<dt>Worker</dt><dd><code>{selectedWorker.worker_id}</code></dd>
|
||||
</dl>
|
||||
</details>
|
||||
{/key}
|
||||
<div class="grant-toolbar">
|
||||
<button type="button" disabled={loading || busy} onclick={() => refresh()}>Refresh grants</button>
|
||||
{#if ready && !unresolved && active.length === 0}
|
||||
<button type="button" disabled={busy} aria-expanded={showCreate} onclick={() => { showCreate = !showCreate; }}>Add Drive grant</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loading}<p role="status">Loading all grant pages…</p>{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if ready && active.length === 0}<p>No active Drive grants for this Worker.</p>{/if}
|
||||
{#if ready && active.length > 0}
|
||||
<p class="hint">Revoke the current grant before changing access.</p>
|
||||
<ul class="grant-list" aria-label="Active Drive grants">
|
||||
{#each active as grant (grant.grant_id)}
|
||||
<li>
|
||||
<span><strong>{grant.access === 'read_only' ? 'Read only' : 'Read and write'}</strong> <span class="hint">Grant {grant.grant_id}</span></span>
|
||||
<button class="revoke" type="button" disabled={busy || loading || unresolved} aria-label={`Revoke Drive grant ${grant.grant_id}`} onclick={() => mutate(grant)}>Revoke</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if showCreate && ready && !unresolved && active.length === 0}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void mutate(); }}>
|
||||
<label>Access
|
||||
<select value={access} onchange={(event) => { const value = event.currentTarget.value; if (value === 'read_only' || value === 'read_write') access = value; }} disabled={busy}>
|
||||
<option value="read_only">Read only</option>
|
||||
<option value="read_write">Read and write</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || loading}>{busy ? 'Saving…' : 'Create Drive grant'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drive-grants { display: grid; gap: var(--space-3); margin-top: var(--space-6); min-width: 0; }
|
||||
h2, p { margin: 0; }
|
||||
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
|
||||
.hint { color: var(--text-muted); }
|
||||
.worker-select, form label { display: grid; gap: var(--space-2); min-width: 0; }
|
||||
select { max-width: 100%; width: 100%; min-width: 0; }
|
||||
button, select { padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text); font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); }
|
||||
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.grant-toolbar, form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-2); }
|
||||
form label { flex: 1 1 12rem; }
|
||||
.grant-list { list-style: none; margin: 0; padding: 0; }
|
||||
li { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding-block: var(--space-2); border-bottom: 1px solid var(--line); }
|
||||
li span { overflow-wrap: anywhere; }
|
||||
.identity summary { cursor: pointer; }
|
||||
.identity summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.identity dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2); margin: var(--space-2) 0 0; }
|
||||
.identity dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.error { overflow-wrap: anywhere; }
|
||||
.error, .revoke { color: var(--danger); }
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
DriveAccess,
|
||||
DriveApiError,
|
||||
DriveGrantCreateRequest,
|
||||
DriveGrantListResponse,
|
||||
DriveGrantResponse,
|
||||
} from "#lib/generated/drive-api.ts";
|
||||
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
|
||||
// Owner-controlled grant transport, separate from ordinary Drive content access.
|
||||
export type { DriveAccess, DriveGrantCreateRequest, DriveGrantResponse };
|
||||
export class DriveGrantError extends Error {
|
||||
constructor(message: string, readonly outcome: "not_committed" | "unknown") {
|
||||
super(message);
|
||||
this.name = "DriveGrantError";
|
||||
}
|
||||
}
|
||||
function object(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Invalid Drive grant response");
|
||||
}
|
||||
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
||||
throw new Error("Unexpected Drive grant response field");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length > 4096) {
|
||||
throw new Error("Invalid Drive grant string");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function nullableText(value: unknown): string | null {
|
||||
return value === null ? null : text(value);
|
||||
}
|
||||
function decimal(value: unknown): string {
|
||||
const id = text(value);
|
||||
if (!/^[1-9][0-9]*$/.test(id) || BigInt(id) > 9223372036854775807n) {
|
||||
throw new Error("Invalid Drive grant ID");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
export function parseDriveGrantResponse(value: unknown): DriveGrantResponse {
|
||||
const v = object(value, [
|
||||
"grant_id",
|
||||
"workspace_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"access",
|
||||
"revoked",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"revoked_by",
|
||||
"revoked_at",
|
||||
]);
|
||||
if (
|
||||
(v.access !== "read_only" && v.access !== "read_write") ||
|
||||
typeof v.revoked !== "boolean"
|
||||
) throw new Error("Invalid Drive grant access or state");
|
||||
return {
|
||||
grant_id: decimal(v.grant_id),
|
||||
workspace_id: text(v.workspace_id),
|
||||
runtime_id: text(v.runtime_id),
|
||||
worker_id: text(v.worker_id),
|
||||
access: v.access,
|
||||
revoked: v.revoked,
|
||||
created_by: text(v.created_by),
|
||||
created_at: text(v.created_at),
|
||||
revoked_by: nullableText(v.revoked_by),
|
||||
revoked_at: nullableText(v.revoked_at),
|
||||
};
|
||||
}
|
||||
export function parseDriveGrantListResponse(
|
||||
value: unknown,
|
||||
): DriveGrantListResponse {
|
||||
const v = object(value, ["grants", "next_after"]);
|
||||
if (!Array.isArray(v.grants) || v.grants.length > 200) {
|
||||
throw new Error("Invalid Drive grant page");
|
||||
}
|
||||
return {
|
||||
grants: v.grants.map(parseDriveGrantResponse),
|
||||
next_after: v.next_after === null ? null : decimal(v.next_after),
|
||||
};
|
||||
}
|
||||
export function parseDriveGrantError(value: unknown): DriveApiError {
|
||||
const v = object(value, ["code", "classification", "message"]);
|
||||
const codes = [
|
||||
"denied",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"invalid",
|
||||
"limit",
|
||||
"storage_unavailable",
|
||||
"outcome_unknown",
|
||||
] as const;
|
||||
const code = codes.find((code) => code === v.code);
|
||||
if (
|
||||
!code ||
|
||||
(v.classification !== "not_committed" && v.classification !== "unknown") ||
|
||||
(code === "outcome_unknown" && v.classification !== "unknown")
|
||||
) throw new Error("Invalid Drive error");
|
||||
return { code, classification: v.classification, message: text(v.message) };
|
||||
}
|
||||
async function request<T>(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
parse: (value: unknown) => T,
|
||||
mutation = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
// Browser same-origin authentication only; Backend rechecks current authority.
|
||||
const response = await fetch(workspaceApiPath(workspaceId, path), {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
});
|
||||
const payload = await readBoundedJson(response, 1024 * 1024);
|
||||
if (!response.ok) {
|
||||
const error = parseDriveGrantError(payload);
|
||||
throw new DriveGrantError(error.message, error.classification);
|
||||
}
|
||||
return parse(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof DriveGrantError) throw error;
|
||||
throw new DriveGrantError(
|
||||
mutation
|
||||
? "Drive grant outcome unknown. Refresh grants before deciding on another action; do not blindly retry."
|
||||
: "Drive grants could not be loaded. Refresh to try reading them again.",
|
||||
mutation ? "unknown" : "not_committed",
|
||||
);
|
||||
}
|
||||
}
|
||||
export async function listDriveGrants(
|
||||
workspaceId: string,
|
||||
): Promise<DriveGrantResponse[]> {
|
||||
const grants: DriveGrantResponse[] = [];
|
||||
let after: string | null = null;
|
||||
const ids = new Set<string>();
|
||||
// Fetch every page before claiming that a Worker has no active grant. Bound
|
||||
// malformed/cyclic cursors and unexpectedly large collections, never truncate.
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const query = new URLSearchParams({ limit: "200" });
|
||||
if (after !== null) query.set("after", after);
|
||||
const result = await request(
|
||||
workspaceId,
|
||||
`/drive/grants?${query}`,
|
||||
{},
|
||||
parseDriveGrantListResponse,
|
||||
);
|
||||
let previousId = after;
|
||||
for (const grant of result.grants) {
|
||||
if (
|
||||
grant.workspace_id !== workspaceId || ids.has(grant.grant_id) ||
|
||||
(previousId !== null && BigInt(grant.grant_id) <= BigInt(previousId))
|
||||
) {
|
||||
throw new DriveGrantError(
|
||||
"Invalid Drive grant page identity or order. Refresh grants.",
|
||||
"not_committed",
|
||||
);
|
||||
}
|
||||
previousId = grant.grant_id;
|
||||
ids.add(grant.grant_id);
|
||||
grants.push(grant);
|
||||
}
|
||||
if (result.next_after === null) return grants;
|
||||
if (
|
||||
!result.grants.length ||
|
||||
result.next_after !== result.grants.at(-1)?.grant_id ||
|
||||
(after !== null && BigInt(result.next_after) <= BigInt(after))
|
||||
) {
|
||||
throw new DriveGrantError(
|
||||
"Invalid Drive grant cursor. Refresh grants.",
|
||||
"not_committed",
|
||||
);
|
||||
}
|
||||
after = result.next_after;
|
||||
}
|
||||
throw new DriveGrantError(
|
||||
"Drive grant list is too large to load completely. No complete grant state is available.",
|
||||
"not_committed",
|
||||
);
|
||||
}
|
||||
export function createDriveGrant(
|
||||
workspaceId: string,
|
||||
input: DriveGrantCreateRequest,
|
||||
): Promise<DriveGrantResponse> {
|
||||
return request(workspaceId, "/drive/grants", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}, (payload) => {
|
||||
const result = parseDriveGrantResponse(payload);
|
||||
if (
|
||||
result.workspace_id !== workspaceId ||
|
||||
result.runtime_id !== input.runtime_id ||
|
||||
result.worker_id !== input.worker_id || result.access !== input.access ||
|
||||
result.revoked
|
||||
) throw new Error("Drive grant identity mismatch");
|
||||
return result;
|
||||
}, true);
|
||||
}
|
||||
export function revokeDriveGrant(
|
||||
workspaceId: string,
|
||||
grant: DriveGrantResponse,
|
||||
): Promise<DriveGrantResponse> {
|
||||
return request(
|
||||
workspaceId,
|
||||
`/drive/grants/${encodeURIComponent(grant.grant_id)}`,
|
||||
{ method: "DELETE" },
|
||||
(payload) => {
|
||||
const result = parseDriveGrantResponse(payload);
|
||||
if (
|
||||
result.workspace_id !== workspaceId ||
|
||||
result.grant_id !== grant.grant_id ||
|
||||
result.runtime_id !== grant.runtime_id ||
|
||||
result.worker_id !== grant.worker_id || !result.revoked
|
||||
) throw new Error("Drive grant identity mismatch");
|
||||
return result;
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"include": [
|
||||
"WorkerDriveGrants.svelte",
|
||||
"api.ts",
|
||||
"WorkerDriveGrants.browser.spec.ts",
|
||||
"../../../routes/w/[workspaceId]/workers/+page.svelte"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import DriveDocument from "./DriveDocument.svelte";
|
||||
afterEach(cleanup);
|
||||
test("Drive Markdown never automatically loads external or authenticated images, scripts or cross Workspace resources", async () => {
|
||||
const { container } = render(DriveDocument, {
|
||||
workspaceId: "ws",
|
||||
text: [
|
||||
"# Shared",
|
||||
"[latest](/w/ws/drive/9007199254740993)",
|
||||
"[other](/w/other/drive/2)",
|
||||
"[external](https://example.test)",
|
||||
"[credentials](https://user:secret@evil.test)",
|
||||
"",
|
||||
"",
|
||||
"<script>window.pwned=true</script><img src=x onerror=alert(1)>",
|
||||
].join("\n\n"),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Shared")
|
||||
);
|
||||
expect(container.querySelectorAll("img,script,iframe,object,embed"))
|
||||
.toHaveLength(0);
|
||||
expect(container.querySelectorAll("a")).toHaveLength(2);
|
||||
const latest = container.querySelector(
|
||||
'a[href="/w/ws/drive/9007199254740993"]',
|
||||
);
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest?.getAttribute("target")).toBeNull();
|
||||
const external = container.querySelector('a[href="https://example.test"]');
|
||||
expect(external?.getAttribute("rel")).toContain("noreferrer");
|
||||
expect(external?.getAttribute("target")).toBe("_blank");
|
||||
expect(container.textContent).toContain("[Image: tracker");
|
||||
expect(container.textContent).toContain("[Image: API");
|
||||
expect(container.textContent).not.toContain("Image: unnamed");
|
||||
expect(container.textContent).toContain("open its Drive URL to view");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { MARKDOWN_WORKSPACE_CONTEXT, type MarkdownWorkspace } from '#lib/workspace/markdown/link-policy.ts';
|
||||
import DriveMarkdownImage from './DriveMarkdownImage.svelte';
|
||||
let { text, workspaceId }: { text: string; workspaceId: string } = $props();
|
||||
setContext<MarkdownWorkspace>(MARKDOWN_WORKSPACE_CONTEXT, () => workspaceId);
|
||||
</script>
|
||||
<DocumentMarkdown {text} imageRenderer={DriveMarkdownImage} />
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
// No Markdown image triggers an automatic network request. This includes external
|
||||
// tracking pixels, API URLs and cross-Workspace images. Open an authorized node URL
|
||||
// explicitly to use the bounded raster viewer.
|
||||
let { text = '', title }: { text?: string; title?: string } = $props();
|
||||
</script>
|
||||
<span class="drive-inline-image" {title}>[Image: {text || 'unnamed'} — open its Drive URL to view]</span>
|
||||
<style>
|
||||
.drive-inline-image { color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { DriveMutationRequest } from "#lib/generated/drive-api.ts";
|
||||
import DrivePage from "./DrivePage.svelte";
|
||||
import { entry } from "../drive/test-fixtures.ts";
|
||||
vi.mock("$app/navigation", () => ({ beforeNavigate: vi.fn() }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("A late image decode error cannot replace the newly selected image preview", async () => {
|
||||
let sequence = 0;
|
||||
const create = vi.spyOn(URL, "createObjectURL").mockImplementation(() =>
|
||||
`blob:http://localhost/image-${++sequence}`
|
||||
);
|
||||
const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
const id = url.searchParams.get("id") ?? "1";
|
||||
if (url.pathname.endsWith("/download")) {
|
||||
return new Response(new Uint8Array(8));
|
||||
}
|
||||
if (id === "1") return Response.json(entry("1", "alpha", "1", "folder"));
|
||||
return Response.json({
|
||||
...entry(id),
|
||||
name: `image-${id}.png`,
|
||||
content_type: "image/png",
|
||||
size: 8,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" });
|
||||
await waitFor(() =>
|
||||
expect(view.container.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"blob:http://localhost/image-1",
|
||||
)
|
||||
);
|
||||
const oldImage = view.container.querySelector("img")!;
|
||||
await view.rerender({ workspaceId: "alpha", nodeId: "3" });
|
||||
await waitFor(() =>
|
||||
expect(view.container.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"blob:http://localhost/image-2",
|
||||
)
|
||||
);
|
||||
expect(view.container.querySelector("img")).not.toBe(oldImage);
|
||||
await fireEvent.error(oldImage);
|
||||
expect(view.container.textContent).not.toContain(
|
||||
"Image could not be decoded",
|
||||
);
|
||||
await fireEvent.error(view.container.querySelector("img")!);
|
||||
expect(view.container.textContent).toContain("Image could not be decoded");
|
||||
expect(create).toHaveBeenCalledTimes(2);
|
||||
expect(revoke).toHaveBeenCalledWith("blob:http://localhost/image-1");
|
||||
});
|
||||
|
||||
test("Refreshing metadata never silently rebases an open rename form onto another writer revision", async () => {
|
||||
let revision = "1";
|
||||
const mutations: DriveMutationRequest[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
const id = url.searchParams.get("id") ?? "1";
|
||||
if (url.pathname.endsWith("/mutate")) {
|
||||
mutations.push(JSON.parse(String(init?.body)));
|
||||
return Response.json({
|
||||
code: "conflict",
|
||||
classification: "not_committed",
|
||||
message: "Revision conflict",
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (url.pathname.endsWith("/read-text")) {
|
||||
return Response.json({
|
||||
entry: entry("2", "alpha", revision),
|
||||
text: "new",
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
return Response.json(
|
||||
entry(
|
||||
id,
|
||||
"alpha",
|
||||
id === "1" ? "1" : revision,
|
||||
id === "1" ? "folder" : "file",
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" });
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole("button", { name: "Rename / move" })).toBeDefined()
|
||||
);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Rename / move" }));
|
||||
await fireEvent.input(view.getByLabelText("Name"), {
|
||||
target: { value: "my-name.txt" },
|
||||
});
|
||||
revision = "2";
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Refresh" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
view.getByRole("button", { name: "Refresh" }).hasAttribute(
|
||||
"disabled",
|
||||
),
|
||||
).toBe(false)
|
||||
);
|
||||
expect(view.container.textContent).toContain("Expected revision 1.");
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Apply rename / move" }),
|
||||
);
|
||||
await waitFor(() => expect(mutations).toHaveLength(1));
|
||||
expect(mutations[0].mutation).toMatchObject({
|
||||
operation: "relocate",
|
||||
expected_revision: "1",
|
||||
name: "my-name.txt",
|
||||
id: { workspace_id: "alpha", node_id: "2" },
|
||||
});
|
||||
await waitFor(() => expect(view.container.textContent).toContain("conflict"));
|
||||
expect((view.getByLabelText("Name") as HTMLInputElement).value).toBe(
|
||||
"my-name.txt",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { createDriveClient, driveDownloadUrl, DRIVE_FILE_MAX_BYTES, DRIVE_RESPONSE_MAX_BYTES, type DriveEntry, parseDriveDecimal } from '#lib/workspace/drive/api.ts';
|
||||
import { DriveController } from '#lib/workspace/drive/controller.svelte.ts';
|
||||
import { driveHref } from '#lib/workspace/resource-links.ts';
|
||||
import { workspaceRoute, formatDate } from '#lib/workspace/api/http.ts';
|
||||
import DriveDocument from './DriveDocument.svelte';
|
||||
import { previewKind, formatBytes, DRIVE_TEXT_MAX_BYTES } from './preview-policy.ts';
|
||||
|
||||
let { workspaceId, nodeId }: { workspaceId: string; nodeId?: string } = $props();
|
||||
const client = createDriveClient();
|
||||
const controller = new DriveController(client);
|
||||
let form = $state<'folder' | 'text' | 'upload' | 'relocate' | 'replace' | null>(null);
|
||||
let formEntry = $state<DriveEntry | null>(null);
|
||||
let name = $state('');
|
||||
let parentId = $state('');
|
||||
let newText = $state('');
|
||||
let file = $state<File | null>(null);
|
||||
let editing = $state(false);
|
||||
let localError = $state<string | null>(null);
|
||||
let ancestors = $state<DriveEntry[]>([]);
|
||||
let breadcrumbError = $state<string | null>(null);
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let imageFailed = $state(false);
|
||||
let searchText = $state('');
|
||||
let includeText = $state(false);
|
||||
let copyState = $state('');
|
||||
let lifetime = 0;
|
||||
let breadcrumbEpoch = 0;
|
||||
let entry = $derived($controller.entry);
|
||||
let draft = $derived($controller.draft);
|
||||
let dirty = $derived(Boolean(draft && draft.text !== draft.baseText));
|
||||
let pending = $derived($controller.receipts.some((r) => r.state === 'pending'));
|
||||
let unresolved = $derived($controller.receipts.some((r) => r.state === 'pending' || r.state === 'unknown'));
|
||||
let denied = $derived($controller.error?.includes('denied') === true || $controller.receipts.some((r) => r.error?.includes('denied')));
|
||||
let kind = $derived(entry ? previewKind(entry) : null);
|
||||
let canWrite = $derived(Boolean(entry && !denied && !$controller.loading && !unresolved));
|
||||
let canEdit = $derived(canWrite && Boolean(draft) && !$controller.truncated && kind !== 'download');
|
||||
|
||||
$effect(() => {
|
||||
const ws = workspaceId;
|
||||
const id = nodeId;
|
||||
++lifetime;
|
||||
form = null; formEntry = null; editing = false; localError = null; file = null; searchText = ''; copyState = '';
|
||||
try {
|
||||
void controller.select(ws, id ? { workspace_id: ws, node_id: parseDriveDecimal(id) } : undefined);
|
||||
} catch { void controller.select(null); localError = 'Invalid Drive node ID.'; }
|
||||
});
|
||||
$effect(() => {
|
||||
const current = $controller.entry;
|
||||
const ws = $controller.workspaceId;
|
||||
const parent = current?.parent;
|
||||
const epoch = ++breadcrumbEpoch;
|
||||
ancestors = []; breadcrumbError = null;
|
||||
if (!ws || !parent) return;
|
||||
const abort = new AbortController();
|
||||
void (async () => {
|
||||
const chain: DriveEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let next: typeof parent | null = parent;
|
||||
try {
|
||||
while (next) {
|
||||
if (seen.has(next.node_id) || chain.length >= 64) throw new Error('Drive ancestry is unavailable');
|
||||
seen.add(next.node_id);
|
||||
const ancestor = await client.metadata(ws, next, abort.signal);
|
||||
if (epoch !== breadcrumbEpoch) return;
|
||||
chain.unshift(ancestor); next = ancestor.parent;
|
||||
}
|
||||
if (epoch === breadcrumbEpoch) ancestors = chain;
|
||||
} catch {
|
||||
if (epoch === breadcrumbEpoch && !abort.signal.aborted) breadcrumbError = 'Folder breadcrumb unavailable. Use Drive root or retry.';
|
||||
}
|
||||
})();
|
||||
return () => { ++breadcrumbEpoch; abort.abort(); };
|
||||
});
|
||||
$effect(() => {
|
||||
const blob = $controller.image;
|
||||
imageFailed = false;
|
||||
if (!blob) { imageUrl = null; return; }
|
||||
const url = URL.createObjectURL(blob);
|
||||
imageUrl = url;
|
||||
return () => { URL.revokeObjectURL(url); };
|
||||
});
|
||||
onDestroy(() => { ++lifetime; ++breadcrumbEpoch; controller.dispose(); });
|
||||
beforeNavigate(({ to, cancel }) => {
|
||||
// Within the optional Drive route the controller keeps per-identity drafts.
|
||||
// Leaving it destroys this session store; make that loss an explicit decision.
|
||||
const isDrive = to?.route.id?.endsWith('/drive/[[nodeId]]') === true;
|
||||
if (!isDrive && (dirty || unresolved) && !confirm('Leave Drive? Unsaved drafts and unresolved request IDs are kept only in this page session. Copy them before leaving.')) cancel();
|
||||
});
|
||||
function openForm(next: typeof form): void {
|
||||
if (!entry || !canWrite) return;
|
||||
form = next; formEntry = entry; localError = null; file = null; newText = '';
|
||||
name = next === 'relocate' ? entry.name : next === 'text' ? 'document.md' : '';
|
||||
parentId = entry.parent?.node_id ?? entry.entry.node_id;
|
||||
}
|
||||
async function action(run: () => Promise<unknown>): Promise<void> {
|
||||
const epoch = lifetime;
|
||||
localError = null;
|
||||
try { await run(); } catch (error) {
|
||||
if (epoch === lifetime) localError = error instanceof Error ? error.message : 'Drive operation failed.';
|
||||
}
|
||||
}
|
||||
async function submitForm(): Promise<void> {
|
||||
const current = formEntry;
|
||||
const selectedForm = form;
|
||||
if (!current || !selectedForm || !canWrite || current.entry.workspace_id !== workspaceId || current.entry.node_id !== entry?.entry.node_id) return;
|
||||
const epoch = lifetime;
|
||||
await action(async () => {
|
||||
let requestId: string;
|
||||
if (selectedForm === 'upload' || selectedForm === 'replace') {
|
||||
if (!file) throw new Error('Choose a file.');
|
||||
if (file.size > DRIVE_FILE_MAX_BYTES) throw new Error('Upload exceeds 16 MiB.');
|
||||
requestId = await controller.upload(file, selectedForm === 'replace' ? {
|
||||
operation: 'update', id: current.entry, expected_revision: current.revision, content_type: file.type || 'application/octet-stream',
|
||||
} : {
|
||||
operation: 'create', parent: current.entry, name: file.name, content_type: file.type || 'application/octet-stream',
|
||||
});
|
||||
} else if (selectedForm === 'relocate') {
|
||||
requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_revision: current.revision, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name });
|
||||
} else {
|
||||
requestId = await controller.mutate(selectedForm === 'folder' ? { operation: 'create_folder', parent: current.entry, name } : { operation: 'create_text', parent: current.entry, name, text: newText, content_type: 'text/markdown' });
|
||||
}
|
||||
if (epoch !== lifetime) return;
|
||||
const receipt = controller.state.receipts.find((r) => r.requestId === requestId);
|
||||
if (receipt?.state === 'committed') {
|
||||
form = null; file = null;
|
||||
await controller.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
async function remove(): Promise<void> {
|
||||
const current = entry;
|
||||
if (!current || !canWrite || !current.parent) return;
|
||||
if (!confirm(`Delete “${current.name}”? This cannot be undone. Folders must be empty; contents are never recursively deleted.`)) return;
|
||||
await action(async () => {
|
||||
await controller.mutate({ operation: 'delete', id: current.entry, expected_revision: current.revision });
|
||||
// Stay on this stable URL; a subsequent lookup correctly reports NotFound.
|
||||
if (controller.state.receipts.at(-1)?.state === 'committed') await controller.refresh();
|
||||
});
|
||||
}
|
||||
async function reconcileReceipt(requestId: string): Promise<void> {
|
||||
const epoch = lifetime;
|
||||
await action(async () => {
|
||||
await controller.reconcile(requestId);
|
||||
if (epoch !== lifetime) return;
|
||||
if (controller.state.receipts.find((r) => r.requestId === requestId)?.state === 'committed') {
|
||||
form = null; file = null;
|
||||
await controller.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
async function copyUrl(): Promise<void> {
|
||||
const current = entry;
|
||||
const epoch = lifetime;
|
||||
if (!current) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(driveHref(current.entry), location.origin).href);
|
||||
if (epoch === lifetime) copyState = 'URL copied';
|
||||
} catch { if (epoch === lifetime) copyState = 'Copy the latest URL below.'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="drive-page" aria-labelledby="drive-heading" data-drive-ready={!$controller.loading}>
|
||||
<header class="drive-heading">
|
||||
<div>
|
||||
<h1 id="drive-heading">{entry?.parent ? entry.name : 'Drive'}</h1>
|
||||
<p class="drive-note">Shared files · latest only. Saving replaces the current content; there is no version history.</p>
|
||||
</div>
|
||||
<button type="button" disabled={$controller.loading || pending} onclick={() => action(() => entry ? controller.refresh() : controller.select(workspaceId, nodeId ? { workspace_id: workspaceId, node_id: nodeId } : undefined))}>Refresh</button>
|
||||
</header>
|
||||
<nav class="drive-breadcrumb" aria-label="Drive folders">
|
||||
<a href={workspaceRoute(workspaceId, '/drive')}>Drive</a>
|
||||
{#each ancestors.filter((a) => a.parent !== null) as ancestor (ancestor.entry.node_id)}
|
||||
<span aria-hidden="true">/</span><a href={driveHref(ancestor.entry)}>{ancestor.name}</a>
|
||||
{/each}
|
||||
{#if entry?.parent}<span aria-hidden="true">/</span><span aria-current="page">{entry.name}</span>{/if}
|
||||
</nav>
|
||||
{#if breadcrumbError}<p class="drive-error" role="status">{breadcrumbError}</p>{/if}
|
||||
{#if localError}<p class="drive-error" role="alert">{localError}</p>{/if}
|
||||
{#if $controller.error}<p class="drive-error" role="alert">{$controller.error}. No empty content or success is inferred from this failure.</p>{/if}
|
||||
{#if denied}<p role="status">Read only / access denied by Backend. Editing and upload are unavailable.</p>{/if}
|
||||
{#if $controller.loading}<p class="drive-state" role="status">Loading Drive…</p>{/if}
|
||||
{#if entry}
|
||||
<div class="drive-metadata">
|
||||
<span>{entry.kind} · {formatBytes(entry.size)}</span>
|
||||
<span>Updated {formatDate(entry.updated_at)} by {entry.updated_by}</span>
|
||||
</div>
|
||||
{#if entry.parent}
|
||||
<div class="drive-toolbar">
|
||||
{#if entry.kind === 'file'}<a class="drive-download" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}>Download</a>{/if}
|
||||
<button type="button" onclick={copyUrl}>Copy latest URL</button>
|
||||
{#if canEdit}<button type="button" onclick={() => editing = !editing}>{editing ? 'View' : 'Edit text'}</button>{/if}
|
||||
{#if canWrite}
|
||||
<button type="button" onclick={() => openForm('relocate')}>Rename / move</button>
|
||||
{#if entry.kind === 'file'}<button type="button" onclick={() => openForm('replace')}>Replace file</button>{/if}
|
||||
<button type="button" class="drive-danger" onclick={remove}>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="drive-url"><a href={driveHref(entry.entry)}>{driveHref(entry.entry)}</a> <span role="status">{copyState}</span></p>
|
||||
{/if}
|
||||
{#if kind === 'folder'}
|
||||
<div class="drive-toolbar">
|
||||
{#if canWrite}
|
||||
<button type="button" onclick={() => openForm('folder')}>New folder</button>
|
||||
<button type="button" onclick={() => openForm('text')}>New Markdown</button>
|
||||
<button type="button" onclick={() => openForm('upload')}>Upload</button>
|
||||
{/if}
|
||||
</div>
|
||||
<form class="drive-search" onsubmit={(event) => { event.preventDefault(); void action(() => controller.search(searchText, includeText)); }}>
|
||||
<label>Search Drive <input type="search" bind:value={searchText} maxlength="256" /></label>
|
||||
<label class="drive-checkbox"><input type="checkbox" bind:checked={includeText} /> Include bounded file text</label>
|
||||
<button type="submit" disabled={$controller.loading || !searchText}>Search</button>
|
||||
{#if $controller.search}<button type="button" onclick={() => action(() => controller.refresh())}>Show folder</button>{/if}
|
||||
</form>
|
||||
{#if $controller.search}<p class="drive-note">Search results across this Drive: {$controller.search.query}</p>{/if}
|
||||
{#if !$controller.loading && !$controller.error && !$controller.entries.length}<p class="drive-state">{$controller.search ? 'No matching files.' : 'This folder is empty.'}</p>{/if}
|
||||
{#if $controller.entries.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard access to the bounded horizontal scroll region.) -->
|
||||
<div class="drive-table-region" tabindex="0" role="region" aria-label="Drive entries">
|
||||
<table class="drive-table">
|
||||
<thead><tr><th>Name</th><th>Kind / size</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{#each $controller.entries as item (item.entry.node_id)}
|
||||
<tr><td><a href={driveHref(item.entry)}>{item.name}</a></td><td>{item.kind} · {formatBytes(item.size)}</td><td>{formatDate(item.updated_at)}<br />{item.updated_by}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $controller.nextAfter}<button type="button" disabled={$controller.loading} onclick={() => action(() => controller.loadMore())}>Load more</button>{/if}
|
||||
{:else if kind === 'markdown' || kind === 'text'}
|
||||
{#if $controller.truncated}<p role="status">Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.</p>{/if}
|
||||
{#if draft?.conflict}
|
||||
<div class="drive-conflict" role="alert"><strong>Revision conflict — your draft is retained.</strong><p>Another writer changed this file. Refresh displays the latest content without changing your draft or its expected revision. Copy your draft before explicitly discarding it; it is never automatically retried.</p></div>
|
||||
{/if}
|
||||
{#if editing && draft}
|
||||
<div class="drive-editor">
|
||||
<label>Draft (expected revision {draft.expectedRevision})
|
||||
<textarea value={draft.text} oninput={(event) => controller.edit(event.currentTarget.value)} readonly={denied} spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="drive-toolbar">
|
||||
{#if canWrite}<button type="button" disabled={!dirty || draft.conflict || new TextEncoder().encode(draft.text).length > DRIVE_TEXT_MAX_BYTES} onclick={() => action(() => controller.save())}>Save</button>{/if}
|
||||
<button type="button" disabled={unresolved} onclick={() => { if (confirm('Discard your draft and load the current Backend revision?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest</button>
|
||||
<span role="status">{dirty ? 'Unsaved draft' : 'Saved content'} · {new TextEncoder().encode(draft.text).length} / 65536 bytes</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $controller.text !== null}
|
||||
<article class="drive-preview" aria-label="Current saved content">
|
||||
{#if $controller.text === ''}<p class="drive-note">Empty file.</p>{:else if kind === 'markdown'}<DriveDocument text={$controller.text} {workspaceId} />{:else}<pre>{$controller.text}</pre>{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{:else if kind === 'image'}
|
||||
{#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}<p role="status">Image exceeds the 256 KiB preview limit. Download to view.</p>
|
||||
{:else if imageFailed}<p class="drive-error" role="alert">Image could not be decoded. Download is available; no image is assumed.</p>
|
||||
{:else if imageUrl}
|
||||
{#key imageUrl}<img class="drive-image" src={imageUrl} alt={entry.name} onerror={(event) => { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key}
|
||||
{/if}
|
||||
{:else if kind === 'download'}<p class="drive-state">This file type is download-only. HTML, SVG and other active content are never rendered here.</p>{/if}
|
||||
{/if}
|
||||
{#if form && entry}
|
||||
<form class="drive-form" onsubmit={(event) => { event.preventDefault(); void submitForm(); }}>
|
||||
<h2>{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}</h2>
|
||||
{#if form === 'replace' || form === 'relocate'}<p class="drive-note">Expected revision {formEntry?.revision}. Refresh does not retarget this form to a newer revision.</p>{/if}
|
||||
{#if form === 'upload' || form === 'replace'}
|
||||
<label>File (maximum 16 MiB)<input type="file" onchange={(event) => file = event.currentTarget.files?.[0] ?? null} disabled={pending} /></label>
|
||||
{#if file}<p>{file.name} · {formatBytes(file.size)}</p>{/if}
|
||||
{:else}
|
||||
<label>Name<input bind:value={name} required maxlength="255" disabled={pending} /></label>
|
||||
{#if form === 'relocate'}
|
||||
<label>Destination folder node ID<input bind:value={parentId} required inputmode="numeric" pattern="[1-9][0-9]*" disabled={pending} /></label>
|
||||
<button type="button" disabled={pending} onclick={() => { const epoch = lifetime; const previous = parentId; void action(async () => { const root = await client.root(workspaceId); if (epoch === lifetime && form === 'relocate' && parentId === previous) parentId = root.entry.node_id; }); }}>Use Drive root</button>
|
||||
<p class="drive-note">Use the folder's stable URL ID. Rename / move updates DB metadata, not a copy of the file.</p>
|
||||
{/if}
|
||||
{#if form === 'text'}<label>Markdown<textarea bind:value={newText} disabled={pending}></textarea></label>{/if}
|
||||
{/if}
|
||||
<div class="drive-toolbar">
|
||||
<button type="submit" disabled={!canWrite || (file !== null && file.size > DRIVE_FILE_MAX_BYTES)}>{pending ? 'Transferring / awaiting DB publication…' : form === 'relocate' ? 'Apply rename / move' : form === 'replace' ? 'Replace' : form === 'upload' ? 'Upload file' : 'Create'}</button>
|
||||
{#if pending}<button type="button" onclick={() => controller.cancelPending()}>Cancel transfer / check outcome</button>{/if}
|
||||
<button type="button" disabled={pending} onclick={() => form = null}>Cancel form</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{#if $controller.receipts.length}
|
||||
<section class="drive-receipts" aria-label="Drive mutation outcomes">
|
||||
{#each $controller.receipts as receipt (receipt.requestId)}
|
||||
<div role="status">
|
||||
<span>{receipt.operation}: {receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'}</span>
|
||||
{#if receipt.error}<p class="drive-error">{receipt.error}</p>{/if}
|
||||
{#if receipt.state === 'unknown'}<button type="button" onclick={() => reconcileReceipt(receipt.requestId)}>Check request result</button>{/if}
|
||||
<details><summary>Request ID</summary><code>{receipt.requestId}</code></details>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.drive-page { display: grid; gap: var(--space-4); min-width: 0; max-width: 100%; }
|
||||
.drive-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--space-4); }
|
||||
.drive-heading > div { min-width: 0; }
|
||||
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); overflow-wrap: anywhere; }
|
||||
h2 { margin: 0; font-size: var(--font-size-body); }
|
||||
p { margin: 0; overflow-wrap: anywhere; }
|
||||
.drive-note, .drive-metadata, .drive-state { color: var(--text-muted); }
|
||||
.drive-note { margin-top: var(--space-2); }
|
||||
.drive-metadata { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--font-size-compact); }
|
||||
.drive-toolbar, .drive-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.drive-breadcrumb { font-size: var(--font-size-compact); }
|
||||
.drive-breadcrumb a, .drive-breadcrumb span, .drive-url { min-width: 0; overflow-wrap: anywhere; }
|
||||
button, .drive-download { padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-strong); font-size: var(--font-size-body); cursor: pointer; }
|
||||
button:hover:not(:disabled), .drive-download:hover { background: var(--interactive-hover); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
a { color: var(--accent); }
|
||||
.drive-error, .drive-danger { color: var(--danger); }
|
||||
.drive-conflict { display: grid; gap: var(--space-2); color: var(--warning); }
|
||||
.drive-search { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: end; }
|
||||
label { display: grid; gap: var(--space-1); min-width: 0; }
|
||||
input, textarea { min-width: 0; max-width: 100%; padding: var(--space-2); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg); color: var(--text); font: inherit; }
|
||||
.drive-checkbox { display: flex; align-items: center; gap: var(--space-2); padding-block: var(--space-2); }
|
||||
.drive-table-region { max-width: 100%; overflow-x: auto; }
|
||||
.drive-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-compact); }
|
||||
th, td { padding: var(--space-3) var(--space-2); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
|
||||
td:first-child { width: 50%; min-width: 10rem; overflow-wrap: anywhere; }
|
||||
td:last-child { overflow-wrap: anywhere; }
|
||||
.drive-form, .drive-editor { display: grid; gap: var(--space-3); padding-block: var(--space-4); border-top: 1px solid var(--line); }
|
||||
textarea { width: 100%; min-height: 12rem; box-sizing: border-box; resize: vertical; font-family: var(--font-mono); }
|
||||
.drive-preview { min-width: 0; }
|
||||
.drive-preview pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
|
||||
.drive-image { display: block; max-width: 100%; max-height: 60vh; object-fit: contain; }
|
||||
.drive-receipts { display: grid; gap: var(--space-3); font-size: var(--font-size-compact); }
|
||||
.drive-receipts code { overflow-wrap: anywhere; }
|
||||
@media (max-width: 600px) { .drive-heading { flex-wrap: wrap; } .drive-search > label:first-child { width: 100%; } }
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { markdownLinkTarget } from "../markdown/link-policy.ts";
|
||||
import { formatBytes, previewKind } from "./preview-policy.ts";
|
||||
import type { DriveEntry } from "#lib/generated/drive-api.ts";
|
||||
declare const Deno: { test(name: string, fn: () => void): void };
|
||||
|
||||
Deno.test("Markdown links admit explicit same Workspace navigation and credential-free external URLs only", () => {
|
||||
const accepted = [
|
||||
"/w/ws/drive/9007199254740993",
|
||||
"/w/ws/tickets/T-1-title",
|
||||
"/w/ws/objectives/O-2",
|
||||
];
|
||||
for (const href of accepted) {
|
||||
if (markdownLinkTarget(href, "ws")?.external !== false) {
|
||||
throw new Error(href);
|
||||
}
|
||||
}
|
||||
for (
|
||||
const href of [
|
||||
"//evil.test",
|
||||
"/api/w/ws/drive/download",
|
||||
"/w/other/drive/1",
|
||||
"/w/ws/drive/0",
|
||||
"/w/ws/drive/1?token=secret",
|
||||
"/w/ws/drive/%2fetc",
|
||||
"/w/ws/drive/..",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"https://user:secret@evil.test",
|
||||
"file:///etc/passwd",
|
||||
]
|
||||
) {
|
||||
if (markdownLinkTarget(href, "ws") !== null) throw new Error(href);
|
||||
}
|
||||
if (!markdownLinkTarget("https://example.test/docs", "ws")?.external) {
|
||||
throw new Error("external link missing");
|
||||
}
|
||||
});
|
||||
Deno.test("Drive preview never infers executable HTML or SVG from a Markdown filename", () => {
|
||||
const entry = { name: "file.md", kind: "file" } as DriveEntry;
|
||||
for (
|
||||
const content_type of [
|
||||
"text/html",
|
||||
"image/svg+xml",
|
||||
"application/xhtml+xml",
|
||||
"application/octet-stream",
|
||||
"application/javascript",
|
||||
]
|
||||
) {
|
||||
if (previewKind({ ...entry, content_type }) !== "download") {
|
||||
throw new Error(content_type);
|
||||
}
|
||||
}
|
||||
for (
|
||||
const content_type of ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
) {
|
||||
if (previewKind({ ...entry, content_type }) !== "image") {
|
||||
throw new Error(content_type);
|
||||
}
|
||||
}
|
||||
if (previewKind({ ...entry, content_type: "text/markdown" }) !== "markdown") {
|
||||
throw new Error("Markdown not supported");
|
||||
}
|
||||
if (formatBytes(0) !== "0 B") {
|
||||
throw new Error("empty file confused with folder");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DriveEntry } from "#lib/generated/drive-api.ts";
|
||||
|
||||
export { DRIVE_TEXT_MAX_BYTES } from "#lib/workspace/drive/api.ts";
|
||||
const RASTER_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
export function previewKind(
|
||||
entry: DriveEntry,
|
||||
): "folder" | "markdown" | "text" | "image" | "download" {
|
||||
if (entry.kind === "folder") return "folder";
|
||||
const media = (entry.content_type ?? "").split(";")[0].trim().toLowerCase();
|
||||
if (RASTER_TYPES.has(media)) return "image";
|
||||
// Never infer a preview from an extension, including .md files declared as HTML/SVG.
|
||||
if (media === "text/markdown") return "markdown";
|
||||
if (media === "text/plain") return "text";
|
||||
return "download";
|
||||
}
|
||||
export function formatBytes(size: number | null): string {
|
||||
if (size === null) return "Folder";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
createDriveClient,
|
||||
DRIVE_FILE_MAX_BYTES,
|
||||
DRIVE_RESPONSE_MAX_BYTES,
|
||||
driveDownloadUrl,
|
||||
DriveRequestError,
|
||||
parseDriveApiError,
|
||||
parseDriveDecimal,
|
||||
parseDriveEntry,
|
||||
parseDriveListResponse,
|
||||
parseDriveMutationResponse,
|
||||
parseDriveReadTextResponse,
|
||||
parseDriveRequestStatusResponse,
|
||||
readBoundedDriveBytes,
|
||||
} from "./api.ts";
|
||||
import { assert, entry, equal, ref, rejects, throws } from "./test-fixtures.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
Deno.test("Drive decimal boundary rejects noncanonical or overflow IDs without losing precision", () => {
|
||||
for (
|
||||
const value of [
|
||||
1,
|
||||
null,
|
||||
"",
|
||||
"0",
|
||||
"01",
|
||||
"+1",
|
||||
"-1",
|
||||
"1e3",
|
||||
"1.0",
|
||||
" 1",
|
||||
"١",
|
||||
"9223372036854775808",
|
||||
"999999999999999999999",
|
||||
]
|
||||
) throws(() => parseDriveDecimal(value));
|
||||
equal(parseDriveDecimal("9007199254740993"), "9007199254740993");
|
||||
equal(parseDriveDecimal("9223372036854775807"), "9223372036854775807");
|
||||
});
|
||||
Deno.test("Drive metadata rejects cross-workspace refs and unsafe latest links", () => {
|
||||
const good = entry();
|
||||
const parsed = parseDriveEntry(good, "alpha");
|
||||
equal(parsed.entry, good.entry);
|
||||
equal(parsed.revision, good.revision);
|
||||
equal(parsed.latest_url, good.latest_url);
|
||||
for (
|
||||
const patch of [
|
||||
{ entry: ref("2", "beta") },
|
||||
{ parent: ref("1", "beta") },
|
||||
{ revision: 4 },
|
||||
{ kind: "directory" },
|
||||
{ size: -1 },
|
||||
{ size: DRIVE_FILE_MAX_BYTES + 1 },
|
||||
{ content_type: "text/plain\r\nx:bad" },
|
||||
{ name: null },
|
||||
{ size: null },
|
||||
...[
|
||||
"https://evil.test/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2",
|
||||
"//evil.test/path",
|
||||
"javascript:alert(1)",
|
||||
"/api/w/beta/drive/download?entry_workspace_id=alpha&id=2",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=beta&id=2",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=3",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2&token=secret",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2&id=2",
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2#fragment",
|
||||
"/api/w/alpha/drive/../drive/download?entry_workspace_id=alpha&id=2",
|
||||
].map((latest_url) => ({ latest_url })),
|
||||
]
|
||||
) throws(() => parseDriveEntry({ ...good, ...patch }, "alpha"));
|
||||
throws(() =>
|
||||
parseDriveEntry({ ...entry("1", "alpha", "1", "folder"), size: 0 }, "alpha")
|
||||
);
|
||||
});
|
||||
Deno.test("Drive pages and text reject malformed elements and multibyte overflow", () => {
|
||||
for (
|
||||
const value of [
|
||||
null,
|
||||
{ entries: [entry(), null], next_after: null },
|
||||
{ entries: [entry(), entry()], next_after: null },
|
||||
{ entries: [], next_after: 2 },
|
||||
{
|
||||
entries: Array.from({ length: 201 }, (_, i) => entry(String(i + 2))),
|
||||
next_after: null,
|
||||
},
|
||||
]
|
||||
) throws(() => parseDriveListResponse(value, "alpha"));
|
||||
for (
|
||||
const patch of [{ truncated: "false" }, { text: "é".repeat(32769) }, {
|
||||
entry: entry("1", "alpha", "1", "folder"),
|
||||
}]
|
||||
) {
|
||||
throws(() =>
|
||||
parseDriveReadTextResponse({
|
||||
entry: entry(),
|
||||
text: "abc",
|
||||
truncated: false,
|
||||
...patch,
|
||||
}, "alpha")
|
||||
);
|
||||
}
|
||||
});
|
||||
Deno.test("Drive receipts reject mismatched request ids and impossible committed states", () => {
|
||||
const response = { request_id: "req", entry: entry() };
|
||||
throws(() => parseDriveMutationResponse(response, "alpha", "other"));
|
||||
for (
|
||||
const v of [
|
||||
{ request_id: "req", state: "committed", response: null },
|
||||
{ request_id: "req", state: "uncommitted", response },
|
||||
{
|
||||
request_id: "req",
|
||||
state: "committed",
|
||||
response: { ...response, request_id: "other" },
|
||||
},
|
||||
{ request_id: "other", state: "uncommitted", response: null },
|
||||
]
|
||||
) throws(() => parseDriveRequestStatusResponse(v, "alpha", "req"));
|
||||
throws(() =>
|
||||
parseDriveApiError({
|
||||
code: "conflict",
|
||||
classification: "unknown",
|
||||
message: "bad",
|
||||
})
|
||||
);
|
||||
throws(() =>
|
||||
parseDriveApiError({
|
||||
code: "outcome_unknown",
|
||||
classification: "not_committed",
|
||||
message: "bad",
|
||||
})
|
||||
);
|
||||
});
|
||||
Deno.test("Drive read bounds reject announced and observed bodies above 256 KiB", async () => {
|
||||
equal(
|
||||
(await readBoundedDriveBytes(
|
||||
new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES)),
|
||||
)).length,
|
||||
DRIVE_RESPONSE_MAX_BYTES,
|
||||
);
|
||||
await rejects(() =>
|
||||
readBoundedDriveBytes(
|
||||
new Response("{}", {
|
||||
headers: { "content-length": String(DRIVE_RESPONSE_MAX_BYTES + 1) },
|
||||
}),
|
||||
)
|
||||
);
|
||||
await rejects(() =>
|
||||
readBoundedDriveBytes(
|
||||
new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES + 1), {
|
||||
headers: { "content-length": "1" },
|
||||
}),
|
||||
)
|
||||
);
|
||||
});
|
||||
Deno.test("Drive client uses current routes with same-origin authentication and binary SHA256 upload", async () => {
|
||||
const blob = new Blob([new Uint8Array([0, 255, 8])]);
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await blob.arrayBuffer(),
|
||||
);
|
||||
const expectedHash = Array.from(
|
||||
new Uint8Array(digest),
|
||||
(b) => b.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
let called = false;
|
||||
const client = createDriveClient((input, init) => {
|
||||
called = true;
|
||||
const url = new URL(String(input), "https://local.test");
|
||||
equal(url.pathname, "/api/w/alpha/drive/upload");
|
||||
equal(url.searchParams.get("parent_id"), "9007199254740993");
|
||||
equal(url.searchParams.get("size"), "3");
|
||||
equal(url.searchParams.get("sha256"), expectedHash);
|
||||
equal((init as RequestInit | undefined)?.credentials, "same-origin");
|
||||
equal((init as RequestInit | undefined)?.cache, "no-store");
|
||||
equal((init as RequestInit | undefined)?.redirect, "error");
|
||||
equal((init as RequestInit | undefined)?.method, "PUT");
|
||||
assert((init as RequestInit | undefined)?.body === blob);
|
||||
equal(
|
||||
new Headers((init as RequestInit | undefined)?.headers).get(
|
||||
"content-type",
|
||||
),
|
||||
"application/octet-stream",
|
||||
);
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
request_id: "req",
|
||||
entry: { ...entry(), name: "raw.bin", parent: ref("9007199254740993") },
|
||||
}),
|
||||
);
|
||||
});
|
||||
await client.upload("alpha", "req", blob, {
|
||||
operation: "create",
|
||||
parent: ref("9007199254740993"),
|
||||
name: "raw.bin",
|
||||
content_type: "application/octet-stream",
|
||||
});
|
||||
assert(called);
|
||||
equal(
|
||||
driveDownloadUrl("alpha", ref("9007199254740993"), "9007199254740995"),
|
||||
"/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_revision=9007199254740995",
|
||||
);
|
||||
});
|
||||
Deno.test("Drive upload over 16 MiB is rejected before fetching", async () => {
|
||||
let calls = 0;
|
||||
const client = createDriveClient(() => {
|
||||
++calls;
|
||||
throw new Error("Must not transmit");
|
||||
});
|
||||
await rejects(() =>
|
||||
client.upload(
|
||||
"alpha",
|
||||
"req",
|
||||
new Blob([new Uint8Array(DRIVE_FILE_MAX_BYTES + 1)]),
|
||||
{
|
||||
operation: "create",
|
||||
parent: ref("1"),
|
||||
name: "raw.bin",
|
||||
content_type: "application/octet-stream",
|
||||
},
|
||||
)
|
||||
);
|
||||
equal(calls, 0);
|
||||
});
|
||||
Deno.test("Drive mutation network abort or malformed reply remains unknown after fetch invocation", async () => {
|
||||
for (
|
||||
const fetchFn of [
|
||||
() => Promise.reject(new TypeError("Network failure")),
|
||||
() => Promise.reject(new DOMException("Aborted", "AbortError")),
|
||||
() => Promise.resolve(new Response("not JSON", { status: 502 })),
|
||||
() =>
|
||||
Promise.resolve(Response.json({ request_id: "wrong", entry: entry() })),
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES + 1)),
|
||||
),
|
||||
]
|
||||
) {
|
||||
const client = createDriveClient(fetchFn);
|
||||
await rejects(
|
||||
() =>
|
||||
client.mutate("alpha", "req", {
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
}),
|
||||
(error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
equal(error.classification, "unknown");
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Deno.test("Drive typed conflict proves not committed and read responses cannot target another node", async () => {
|
||||
const client = createDriveClient(() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
code: "conflict",
|
||||
classification: "not_committed",
|
||||
message: "conflict",
|
||||
}, { status: 409 }),
|
||||
)
|
||||
);
|
||||
await rejects(
|
||||
() =>
|
||||
client.mutate("alpha", "req", {
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
}),
|
||||
(error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
equal(error.classification, "not_committed");
|
||||
},
|
||||
);
|
||||
const wrong = createDriveClient(() =>
|
||||
Promise.resolve(
|
||||
Response.json({ entry: entry("3"), text: "abc", truncated: false }),
|
||||
)
|
||||
);
|
||||
await rejects(() => wrong.readText("alpha", ref()));
|
||||
});
|
||||
Deno.test("Drive image preview fetches authenticated bounded binary rather than metadata URL", async () => {
|
||||
const image = { ...entry(), content_type: "image/png" };
|
||||
const client = createDriveClient((url, init) => {
|
||||
equal(String(url), driveDownloadUrl("alpha", ref(), image.revision));
|
||||
equal((init as RequestInit | undefined)?.credentials, "same-origin");
|
||||
equal((init as RequestInit | undefined)?.cache, "no-store");
|
||||
return Promise.resolve(new Response(new Uint8Array([0, 1, 2])));
|
||||
});
|
||||
equal((await client.image("alpha", image)).size, 3);
|
||||
await rejects(() =>
|
||||
client.image("alpha", { ...image, content_type: "image/svg+xml" })
|
||||
);
|
||||
await rejects(() =>
|
||||
client.image("alpha", { ...image, size: DRIVE_RESPONSE_MAX_BYTES + 1 })
|
||||
);
|
||||
await rejects(() =>
|
||||
client.image("alpha", { ...image, content_type: "image/avif" })
|
||||
);
|
||||
});
|
||||
Deno.test("Drive malformed mutation response target parent name or operation kind remains unknown", async () => {
|
||||
const cases: {
|
||||
mutation: import("./api.ts").DriveMutation;
|
||||
response: unknown;
|
||||
}[] = [
|
||||
{
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("2", "alpha", "2", "folder"),
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
response: { ...entry(), parent: ref("4"), name: "wrong.txt" },
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
response: { ...entry(), name: "moved.txt" },
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "relocate",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
parent: ref("4"),
|
||||
name: "moved.txt",
|
||||
},
|
||||
response: { ...entry("3"), parent: ref("4"), name: "moved.txt" },
|
||||
},
|
||||
{
|
||||
mutation: { operation: "create_folder", parent: ref("4"), name: "new" },
|
||||
response: { ...entry("3", "alpha", "1", "folder"), name: "new" },
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "create_folder",
|
||||
parent: ref("1"),
|
||||
name: "note.txt",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "create_text",
|
||||
parent: ref("1"),
|
||||
name: "note.txt",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3", "alpha", "1", "folder"),
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "create_text",
|
||||
parent: ref("1"),
|
||||
name: "other.txt",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
mutation: {
|
||||
operation: "update_text",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
text: "abc",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: null,
|
||||
},
|
||||
{
|
||||
mutation: { operation: "delete", id: ref(), expected_revision: "1" },
|
||||
response: entry(),
|
||||
},
|
||||
];
|
||||
for (const { mutation, response } of cases) {
|
||||
const client = createDriveClient(() =>
|
||||
Promise.resolve(Response.json({ request_id: "req", entry: response }))
|
||||
);
|
||||
await rejects(() => client.mutate("alpha", "req", mutation), (error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
equal(error.classification, "unknown");
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("Drive malformed upload update and create identity remains unknown after transmission", async () => {
|
||||
const cases: {
|
||||
target: import("./api.ts").DriveUploadTarget;
|
||||
response: unknown;
|
||||
}[] = [
|
||||
{
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("2", "alpha", "2", "folder"),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
operation: "create",
|
||||
parent: ref("4"),
|
||||
name: "note.txt",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
operation: "create",
|
||||
parent: ref("1"),
|
||||
name: "other.txt",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3"),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
operation: "create",
|
||||
parent: ref("1"),
|
||||
name: "note.txt",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: entry("3", "alpha", "1", "folder"),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "1",
|
||||
content_type: "text/plain",
|
||||
},
|
||||
response: null,
|
||||
},
|
||||
];
|
||||
for (const { target, response } of cases) {
|
||||
const client = createDriveClient(() =>
|
||||
Promise.resolve(Response.json({ request_id: "req", entry: response }))
|
||||
);
|
||||
await rejects(
|
||||
() => client.upload("alpha", "req", new Blob(["abc"]), target),
|
||||
(error) => {
|
||||
assert(error instanceof DriveRequestError);
|
||||
equal(error.classification, "unknown");
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Drive bounded text roundtrip accepts worst-case JSON escaping without increasing raster bytes", async () => {
|
||||
const text = "\u0000".repeat(64 * 1024);
|
||||
const payload = JSON.stringify({
|
||||
entry: { ...entry(), size: 64 * 1024 },
|
||||
text,
|
||||
truncated: false,
|
||||
});
|
||||
assert(
|
||||
new TextEncoder().encode(payload).length > DRIVE_RESPONSE_MAX_BYTES,
|
||||
"fixture must cross the old binary-sized wire bound",
|
||||
);
|
||||
const client = createDriveClient(() =>
|
||||
Promise.resolve(new Response(payload))
|
||||
);
|
||||
const result = await client.readText("alpha", ref());
|
||||
assert(
|
||||
result.text === text && !result.truncated,
|
||||
"valid 64 KiB UTF-8 text must survive JSON escapes",
|
||||
);
|
||||
equal(result.entry.size, 64 * 1024);
|
||||
});
|
||||
@@ -0,0 +1,637 @@
|
||||
import type {
|
||||
DriveApiError,
|
||||
DriveEntry,
|
||||
DriveEntryRef,
|
||||
DriveListResponse,
|
||||
DriveMutation,
|
||||
DriveMutationResponse,
|
||||
DriveReadTextResponse,
|
||||
DriveRequestStatusResponse,
|
||||
} from "#lib/generated/drive-api.ts";
|
||||
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
|
||||
export type {
|
||||
DriveEntry,
|
||||
DriveEntryRef,
|
||||
DriveMutation,
|
||||
} from "#lib/generated/drive-api.ts";
|
||||
export const DRIVE_RESPONSE_MAX_BYTES = 256 * 1024;
|
||||
// A 64 KiB UTF-8 text can expand to six times its length in JSON escapes.
|
||||
// Keep the JSON wire bound separate from the raster preview byte limit.
|
||||
export const DRIVE_JSON_MAX_BYTES = 512 * 1024;
|
||||
export const DRIVE_TEXT_MAX_BYTES = 64 * 1024;
|
||||
export const DRIVE_FILE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_DECIMAL = "9223372036854775807";
|
||||
|
||||
export class DriveContractError extends Error {
|
||||
constructor() {
|
||||
super("Invalid Drive payload");
|
||||
}
|
||||
}
|
||||
export class DriveRequestError extends Error {
|
||||
constructor(
|
||||
public readonly code: DriveApiError["code"],
|
||||
public readonly classification: DriveApiError["classification"],
|
||||
) {
|
||||
super(
|
||||
classification === "unknown"
|
||||
? "Drive outcome unknown; check request status"
|
||||
: `Drive request failed: ${code}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
function invalid(): never {
|
||||
throw new DriveContractError();
|
||||
}
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function string(value: unknown, maximum = 4096): string {
|
||||
if (typeof value !== "string" || value.length > maximum) invalid();
|
||||
return value;
|
||||
}
|
||||
function boolean(value: unknown): boolean {
|
||||
if (typeof value !== "boolean") invalid();
|
||||
return value;
|
||||
}
|
||||
function nullableString(value: unknown): string | null {
|
||||
return value === null ? null : string(value);
|
||||
}
|
||||
export function parseDriveDecimal(value: unknown): string {
|
||||
const result = string(value, 19);
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(result) ||
|
||||
(result.length === 19 && result > MAX_DECIMAL)
|
||||
) invalid();
|
||||
return result;
|
||||
}
|
||||
function hasControl(value: string, includeSpace = false): boolean {
|
||||
return Array.from(value).some((char) =>
|
||||
char.charCodeAt(0) <= (includeSpace ? 32 : 31)
|
||||
);
|
||||
}
|
||||
function workspace(value: string): string {
|
||||
if (
|
||||
!value || value === "." || value === ".." || hasControl(value, true) ||
|
||||
/[/\\?#]/.test(value)
|
||||
) invalid();
|
||||
return string(value, 256);
|
||||
}
|
||||
export function parseDriveEntryRef(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
): DriveEntryRef {
|
||||
const v = record(value);
|
||||
if (string(v.workspace_id) !== workspace(workspaceId)) invalid();
|
||||
return { workspace_id: workspaceId, node_id: parseDriveDecimal(v.node_id) };
|
||||
}
|
||||
function base(workspaceId: string): string {
|
||||
return workspaceApiPath(workspace(workspaceId), "/drive");
|
||||
}
|
||||
function query(
|
||||
values: Record<string, string | number | boolean | null | undefined>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== null && value !== undefined) params.set(key, String(value));
|
||||
}
|
||||
return `?${params}`;
|
||||
}
|
||||
function refQuery(
|
||||
ref: DriveEntryRef,
|
||||
workspaceId: string,
|
||||
): { entry_workspace_id: string; id: string } {
|
||||
const parsed = parseDriveEntryRef(ref, workspaceId);
|
||||
return { entry_workspace_id: parsed.workspace_id, id: parsed.node_id };
|
||||
}
|
||||
/** Only service latest links for this exact entry, without credentials or extra query keys. */
|
||||
export function parseDriveLatestUrl(
|
||||
value: unknown,
|
||||
entry: DriveEntryRef,
|
||||
kind: DriveEntry["kind"],
|
||||
): string {
|
||||
const path = string(value);
|
||||
const endpoint = `${base(entry.workspace_id)}/${
|
||||
kind === "file" ? "download" : "metadata"
|
||||
}`;
|
||||
if (
|
||||
!path.startsWith(`${endpoint}?`) || hasControl(path, true) ||
|
||||
/[\\#]/.test(path)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const params = new URLSearchParams(path.slice(endpoint.length + 1));
|
||||
if (
|
||||
[...params].length !== 2 ||
|
||||
params.get("entry_workspace_id") !== entry.workspace_id ||
|
||||
params.get("id") !== entry.node_id
|
||||
) invalid();
|
||||
return path;
|
||||
}
|
||||
function mime(value: unknown): string {
|
||||
const result = string(value, 128);
|
||||
if (!/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(result)) invalid();
|
||||
return result.toLowerCase();
|
||||
}
|
||||
export function parseDriveEntry(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
): DriveEntry {
|
||||
const v = record(value);
|
||||
const entry = parseDriveEntryRef(v.entry, workspaceId);
|
||||
const parent = v.parent === null
|
||||
? null
|
||||
: parseDriveEntryRef(v.parent, workspaceId);
|
||||
if (v.kind !== "folder" && v.kind !== "file") invalid();
|
||||
const size = v.size;
|
||||
if (v.kind === "file") {
|
||||
if (
|
||||
typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 ||
|
||||
size > DRIVE_FILE_MAX_BYTES || parent === null
|
||||
) invalid();
|
||||
} else if (size !== null || v.content_type !== null) invalid();
|
||||
return {
|
||||
entry,
|
||||
parent,
|
||||
kind: v.kind,
|
||||
name: string(v.name, 255),
|
||||
revision: parseDriveDecimal(v.revision),
|
||||
size: size as number | null,
|
||||
content_type: v.content_type === null ? null : mime(v.content_type),
|
||||
updated_by: string(v.updated_by),
|
||||
updated_at: string(v.updated_at, 128),
|
||||
latest_url: parseDriveLatestUrl(v.latest_url, entry, v.kind),
|
||||
};
|
||||
}
|
||||
export function parseDriveListResponse(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
maximum = 200,
|
||||
): DriveListResponse {
|
||||
const v = record(value);
|
||||
if (!Array.isArray(v.entries) || v.entries.length > maximum) invalid();
|
||||
const entries = v.entries.map((entry) => parseDriveEntry(entry, workspaceId));
|
||||
if (
|
||||
new Set(entries.map((entry) => entry.entry.node_id)).size !== entries.length
|
||||
) invalid();
|
||||
return { entries, next_after: nullableString(v.next_after) };
|
||||
}
|
||||
export function parseDriveReadTextResponse(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
): DriveReadTextResponse {
|
||||
const v = record(value);
|
||||
const entry = parseDriveEntry(v.entry, workspaceId);
|
||||
const text = string(v.text, DRIVE_TEXT_MAX_BYTES);
|
||||
if (
|
||||
entry.kind !== "file" ||
|
||||
new TextEncoder().encode(text).length > DRIVE_TEXT_MAX_BYTES
|
||||
) invalid();
|
||||
return { entry, text, truncated: boolean(v.truncated) };
|
||||
}
|
||||
export function parseDriveMutationResponse(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
requestId: string,
|
||||
): DriveMutationResponse {
|
||||
const v = record(value);
|
||||
if (v.request_id !== requestId) invalid();
|
||||
return {
|
||||
request_id: requestId,
|
||||
entry: v.entry === null ? null : parseDriveEntry(v.entry, workspaceId),
|
||||
};
|
||||
}
|
||||
export function parseDriveRequestStatusResponse(
|
||||
value: unknown,
|
||||
workspaceId: string,
|
||||
requestId: string,
|
||||
): DriveRequestStatusResponse {
|
||||
const v = record(value);
|
||||
if (
|
||||
v.request_id !== requestId ||
|
||||
(v.state !== "committed" && v.state !== "uncommitted")
|
||||
) invalid();
|
||||
if ((v.state === "uncommitted") !== (v.response === null)) invalid();
|
||||
return {
|
||||
request_id: requestId,
|
||||
state: v.state,
|
||||
response: v.response === null
|
||||
? null
|
||||
: parseDriveMutationResponse(v.response, workspaceId, requestId),
|
||||
};
|
||||
}
|
||||
export function parseDriveApiError(value: unknown): DriveApiError {
|
||||
const v = record(value);
|
||||
const codes = [
|
||||
"denied",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"invalid",
|
||||
"limit",
|
||||
"storage_unavailable",
|
||||
"outcome_unknown",
|
||||
];
|
||||
if (
|
||||
!codes.includes(v.code as string) ||
|
||||
(v.classification !== "not_committed" && v.classification !== "unknown") ||
|
||||
(v.code === "outcome_unknown") !== (v.classification === "unknown")
|
||||
) invalid();
|
||||
return {
|
||||
code: v.code as DriveApiError["code"],
|
||||
classification: v.classification,
|
||||
message: string(v.message),
|
||||
};
|
||||
}
|
||||
export async function readBoundedDriveBytes(
|
||||
response: Response,
|
||||
maximum = DRIVE_RESPONSE_MAX_BYTES,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const length = response.headers.get("content-length");
|
||||
if (
|
||||
length !== null &&
|
||||
(!/^[0-9]+$/.test(length) || BigInt(length) > BigInt(maximum))
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
invalid();
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) invalid();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximum) {
|
||||
await reader.cancel();
|
||||
invalid();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
export type DriveUploadTarget =
|
||||
| {
|
||||
operation: "create";
|
||||
parent: DriveEntryRef;
|
||||
name: string;
|
||||
content_type: string;
|
||||
}
|
||||
| {
|
||||
operation: "update";
|
||||
id: DriveEntryRef;
|
||||
expected_revision: string;
|
||||
content_type: string;
|
||||
};
|
||||
export type DrivePageOptions = { after?: string | null; limit?: number };
|
||||
export interface DriveClient {
|
||||
root(workspaceId: string, signal?: AbortSignal): Promise<DriveEntry>;
|
||||
metadata(
|
||||
workspaceId: string,
|
||||
ref: DriveEntryRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveEntry>;
|
||||
list(
|
||||
workspaceId: string,
|
||||
ref: DriveEntryRef,
|
||||
options?: DrivePageOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveListResponse>;
|
||||
search(
|
||||
workspaceId: string,
|
||||
text: string,
|
||||
includeText?: boolean,
|
||||
options?: DrivePageOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveListResponse>;
|
||||
readText(
|
||||
workspaceId: string,
|
||||
ref: DriveEntryRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveReadTextResponse>;
|
||||
image(
|
||||
workspaceId: string,
|
||||
entry: DriveEntry,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob>;
|
||||
mutate(
|
||||
workspaceId: string,
|
||||
requestId: string,
|
||||
mutation: DriveMutation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveMutationResponse>;
|
||||
upload(
|
||||
workspaceId: string,
|
||||
requestId: string,
|
||||
blob: Blob,
|
||||
target: DriveUploadTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveMutationResponse>;
|
||||
status(
|
||||
workspaceId: string,
|
||||
requestId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DriveRequestStatusResponse>;
|
||||
}
|
||||
export function driveDownloadUrl(
|
||||
workspaceId: string,
|
||||
ref: DriveEntryRef,
|
||||
revision?: string,
|
||||
): string {
|
||||
return `${base(workspaceId)}/download${
|
||||
query({
|
||||
...refQuery(ref, workspaceId),
|
||||
expected_revision: revision === undefined
|
||||
? null
|
||||
: parseDriveDecimal(revision),
|
||||
})
|
||||
}`;
|
||||
}
|
||||
function requestId(value: string): string {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function name(value: string): string {
|
||||
if (
|
||||
!value || value === "." || value === ".." || hasControl(value) ||
|
||||
/[/\\]/.test(value) ||
|
||||
new TextEncoder().encode(value).length > 255
|
||||
) invalid();
|
||||
return value;
|
||||
}
|
||||
function validateMutation(value: DriveMutation, workspaceId: string): void {
|
||||
if ("id" in value) {
|
||||
parseDriveEntryRef(value.id, workspaceId);
|
||||
parseDriveDecimal(value.expected_revision);
|
||||
}
|
||||
if ("parent" in value) parseDriveEntryRef(value.parent, workspaceId);
|
||||
if ("name" in value) name(value.name);
|
||||
if ("text" in value) {
|
||||
if (new TextEncoder().encode(value.text).length > DRIVE_TEXT_MAX_BYTES) {
|
||||
throw new DriveRequestError("limit", "not_committed");
|
||||
}
|
||||
mime(value.content_type);
|
||||
}
|
||||
}
|
||||
function page(options: DrivePageOptions, maximum: number): DrivePageOptions {
|
||||
if (
|
||||
options.limit !== undefined &&
|
||||
(!Number.isInteger(options.limit) || options.limit < 1 ||
|
||||
options.limit > maximum)
|
||||
) invalid();
|
||||
if (options.after !== undefined && options.after !== null) {
|
||||
string(options.after);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient {
|
||||
async function response(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
mutation = false,
|
||||
): Promise<Response> {
|
||||
// Fetch invocation is the conservative transmission boundary. An abort/network
|
||||
// error after this point cannot prove that the server did not commit.
|
||||
try {
|
||||
return await fetchFn(path, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
});
|
||||
} catch {
|
||||
throw new DriveRequestError(
|
||||
mutation ? "outcome_unknown" : "storage_unavailable",
|
||||
mutation ? "unknown" : "not_committed",
|
||||
);
|
||||
}
|
||||
}
|
||||
async function checked(res: Response): Promise<void> {
|
||||
if (res.ok) return;
|
||||
const payload: unknown = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await readBoundedDriveBytes(res, DRIVE_JSON_MAX_BYTES),
|
||||
),
|
||||
);
|
||||
const error = parseDriveApiError(payload);
|
||||
throw new DriveRequestError(error.code, error.classification);
|
||||
}
|
||||
async function json<T>(
|
||||
path: string,
|
||||
parse: (value: unknown) => T,
|
||||
init?: RequestInit,
|
||||
mutation = false,
|
||||
): Promise<T> {
|
||||
const res = await response(path, init, mutation);
|
||||
try {
|
||||
await checked(res);
|
||||
return parse(
|
||||
JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await readBoundedDriveBytes(res, DRIVE_JSON_MAX_BYTES),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DriveRequestError) throw error;
|
||||
if (mutation) throw new DriveRequestError("outcome_unknown", "unknown");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function exact(entry: DriveEntry, ref: DriveEntryRef): DriveEntry {
|
||||
if (entry.entry.node_id !== ref.node_id) invalid();
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
root: (ws, signal) =>
|
||||
json(`${base(ws)}/root`, (v) => {
|
||||
const entry = parseDriveEntry(v, ws);
|
||||
if (entry.kind !== "folder" || entry.parent !== null) invalid();
|
||||
return entry;
|
||||
}, { signal }),
|
||||
metadata: (ws, ref, signal) =>
|
||||
json(
|
||||
`${base(ws)}/metadata${query(refQuery(ref, ws))}`,
|
||||
(v) => exact(parseDriveEntry(v, ws), ref),
|
||||
{ signal },
|
||||
),
|
||||
list: (ws, ref, options = {}, signal) =>
|
||||
json(
|
||||
`${base(ws)}/list${
|
||||
query({ ...refQuery(ref, ws), ...page(options, 200) })
|
||||
}`,
|
||||
(v) => {
|
||||
const result = parseDriveListResponse(v, ws);
|
||||
if (
|
||||
result.entries.some((e) => e.parent?.node_id !== ref.node_id)
|
||||
) invalid();
|
||||
return result;
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
search: (ws, text, includeText = false, options = {}, signal) =>
|
||||
json(
|
||||
`${base(ws)}/search${
|
||||
query({
|
||||
query: string(text),
|
||||
include_text: includeText,
|
||||
...page(options, 128),
|
||||
})
|
||||
}`,
|
||||
(v) => parseDriveListResponse(v, ws, 128),
|
||||
{ signal },
|
||||
),
|
||||
readText: (ws, ref, signal) =>
|
||||
json(
|
||||
`${base(ws)}/read-text${
|
||||
query({ ...refQuery(ref, ws), max_bytes: DRIVE_TEXT_MAX_BYTES })
|
||||
}`,
|
||||
(v) => {
|
||||
const result = parseDriveReadTextResponse(v, ws);
|
||||
exact(result.entry, ref);
|
||||
return result;
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
image: async (ws, entry, signal) => {
|
||||
parseDriveEntry(entry, ws);
|
||||
// Do not render active SVG or HTML as a preview.
|
||||
if (
|
||||
!/^(image\/(png|jpeg|gif|webp))$/.test(entry.content_type ?? "") ||
|
||||
entry.size === null || entry.size > DRIVE_RESPONSE_MAX_BYTES
|
||||
) throw new DriveRequestError("limit", "not_committed");
|
||||
const res = await response(
|
||||
driveDownloadUrl(ws, entry.entry, entry.revision),
|
||||
{ signal },
|
||||
);
|
||||
await checked(res);
|
||||
const bytes = await readBoundedDriveBytes(res, DRIVE_RESPONSE_MAX_BYTES);
|
||||
if (bytes.length !== entry.size) invalid();
|
||||
return new Blob([bytes], { type: entry.content_type! });
|
||||
},
|
||||
mutate: (ws, id, mutation, signal) => {
|
||||
validateMutation(mutation, ws);
|
||||
return json(
|
||||
`${base(ws)}/mutate`,
|
||||
(v) => {
|
||||
const result = parseDriveMutationResponse(v, ws, id);
|
||||
if (mutation.operation === "delete") {
|
||||
if (result.entry !== null) invalid();
|
||||
} else {
|
||||
if (result.entry === null) invalid();
|
||||
if (
|
||||
mutation.operation === "create_folder" &&
|
||||
result.entry.kind !== "folder"
|
||||
) invalid();
|
||||
if (
|
||||
(mutation.operation === "create_text" ||
|
||||
mutation.operation === "update_text") &&
|
||||
result.entry.kind !== "file"
|
||||
) invalid();
|
||||
if ("name" in mutation && result.entry.name !== mutation.name) {
|
||||
invalid();
|
||||
}
|
||||
if (
|
||||
"id" in mutation &&
|
||||
result.entry.entry.node_id !== mutation.id.node_id
|
||||
) invalid();
|
||||
if (
|
||||
"parent" in mutation &&
|
||||
result.entry.parent?.node_id !== mutation.parent.node_id
|
||||
) invalid();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ request_id: requestId(id), mutation }),
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
upload: async (ws, id, blob, target, signal) => {
|
||||
base(ws);
|
||||
requestId(id);
|
||||
if (blob.size > DRIVE_FILE_MAX_BYTES) {
|
||||
throw new DriveRequestError("limit", "not_committed");
|
||||
}
|
||||
const contentType = mime(target.content_type);
|
||||
const fields = target.operation === "create"
|
||||
? {
|
||||
parent_id: parseDriveEntryRef(target.parent, ws).node_id,
|
||||
name: name(target.name),
|
||||
}
|
||||
: {
|
||||
id: parseDriveEntryRef(target.id, ws).node_id,
|
||||
expected_revision: parseDriveDecimal(target.expected_revision),
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await blob.arrayBuffer(),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const sha256 = Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
return json(
|
||||
`${base(ws)}/upload${
|
||||
query({
|
||||
operation: target.operation,
|
||||
request_id: id,
|
||||
entry_workspace_id: ws,
|
||||
...fields,
|
||||
content_type: contentType,
|
||||
size: blob.size,
|
||||
sha256,
|
||||
})
|
||||
}`,
|
||||
(v) => {
|
||||
const result = parseDriveMutationResponse(v, ws, id);
|
||||
if (result.entry === null || result.entry.kind !== "file") invalid();
|
||||
if (
|
||||
target.operation === "create" && result.entry.name !== target.name
|
||||
) invalid();
|
||||
if (
|
||||
target.operation === "update" &&
|
||||
result.entry.entry.node_id !== target.id.node_id
|
||||
) invalid();
|
||||
if (
|
||||
target.operation === "create" &&
|
||||
result.entry.parent?.node_id !== target.parent.node_id
|
||||
) invalid();
|
||||
return result;
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
signal,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
body: blob,
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
status: (ws, id, signal) =>
|
||||
json(
|
||||
`${base(ws)}/requests/${encodeURIComponent(requestId(id))}`,
|
||||
(v) => parseDriveRequestStatusResponse(v, ws, id),
|
||||
{ signal },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import type { DriveMutationResponse } from "#lib/generated/drive-api.ts";
|
||||
import {
|
||||
createDriveClient,
|
||||
DRIVE_RESPONSE_MAX_BYTES,
|
||||
DRIVE_TEXT_MAX_BYTES,
|
||||
type DriveClient,
|
||||
type DriveEntry,
|
||||
type DriveEntryRef,
|
||||
type DriveMutation,
|
||||
DriveRequestError,
|
||||
type DriveUploadTarget,
|
||||
parseDriveEntryRef,
|
||||
} from "./api.ts";
|
||||
|
||||
export type DriveDraft = {
|
||||
text: string;
|
||||
baseText: string;
|
||||
expectedRevision: string;
|
||||
contentType: string;
|
||||
conflict: boolean;
|
||||
};
|
||||
export type DriveReceipt = {
|
||||
requestId: string;
|
||||
state: "pending" | "committed" | "not_committed" | "unknown";
|
||||
operation: DriveMutation["operation"] | "upload";
|
||||
error: string | null;
|
||||
response: DriveMutationResponse | null;
|
||||
};
|
||||
export type DriveControllerState = {
|
||||
workspaceId: string | null;
|
||||
entry: DriveEntry | null;
|
||||
entries: DriveEntry[];
|
||||
nextAfter: string | null;
|
||||
search: { query: string; includeText: boolean } | null;
|
||||
text: string | null;
|
||||
truncated: boolean;
|
||||
image: Blob | null;
|
||||
draft: DriveDraft | null;
|
||||
receipts: DriveReceipt[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
type ReceiptRecord = {
|
||||
receipt: DriveReceipt;
|
||||
saved?: { text: string; revision: string };
|
||||
};
|
||||
const empty = (): DriveControllerState => ({
|
||||
workspaceId: null,
|
||||
entry: null,
|
||||
entries: [],
|
||||
nextAfter: null,
|
||||
search: null,
|
||||
text: null,
|
||||
truncated: false,
|
||||
image: null,
|
||||
draft: null,
|
||||
receipts: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
function key(ws: string, ref: DriveEntryRef): string {
|
||||
return JSON.stringify([ws, ref.node_id]);
|
||||
}
|
||||
function message(error: unknown): string {
|
||||
return error instanceof DriveRequestError
|
||||
? error.message
|
||||
: "Drive request could not be completed";
|
||||
}
|
||||
/** A subscribable snapshot store; Svelte consumers may use `$controller` directly.
|
||||
* All asynchronous publication is fenced by selection generation. Aborting a
|
||||
* write does not establish failure: its receipt is retained for reconciliation.
|
||||
*/
|
||||
export class DriveController {
|
||||
private value = empty();
|
||||
private listeners = new Set<(state: DriveControllerState) => void>();
|
||||
private drafts = new Map<string, DriveDraft>();
|
||||
private receipts = new Map<string, Map<string, ReceiptRecord>>();
|
||||
private generation = 0;
|
||||
private pageGeneration = 0;
|
||||
private abort = new AbortController();
|
||||
private writeAbort = new AbortController();
|
||||
private writeGeneration = 0;
|
||||
private disposed = false;
|
||||
constructor(
|
||||
private readonly client: DriveClient = createDriveClient(),
|
||||
private readonly newRequestId: () => string = () => crypto.randomUUID(),
|
||||
) {}
|
||||
get state(): DriveControllerState {
|
||||
return this.value;
|
||||
}
|
||||
subscribe(listener: (state: DriveControllerState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.value);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
private publish(patch: Partial<DriveControllerState>): void {
|
||||
this.value = { ...this.value, ...patch };
|
||||
for (const listener of this.listeners) listener(this.value);
|
||||
}
|
||||
private current(generation: number): boolean {
|
||||
return !this.disposed && generation === this.generation;
|
||||
}
|
||||
private selection(): {
|
||||
ws: string;
|
||||
entry: DriveEntry;
|
||||
key: string;
|
||||
generation: number;
|
||||
signal: AbortSignal;
|
||||
} {
|
||||
if (this.disposed || !this.value.workspaceId || !this.value.entry) {
|
||||
throw new Error("Select a Drive entry first");
|
||||
}
|
||||
return {
|
||||
ws: this.value.workspaceId,
|
||||
entry: this.value.entry,
|
||||
key: key(this.value.workspaceId, this.value.entry.entry),
|
||||
generation: this.generation,
|
||||
signal: this.abort.signal,
|
||||
};
|
||||
}
|
||||
private currentReceipts(k: string): DriveReceipt[] {
|
||||
return [...(this.receipts.get(k)?.values() ?? [])].map((record) =>
|
||||
record.receipt
|
||||
);
|
||||
}
|
||||
private cancelWrites(reason: string): void {
|
||||
++this.writeGeneration;
|
||||
for (const records of this.receipts.values()) {
|
||||
for (const record of records.values()) {
|
||||
if (record.receipt.state === "pending") {
|
||||
record.receipt = {
|
||||
...record.receipt,
|
||||
state: "unknown",
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
this.writeAbort.abort();
|
||||
this.writeAbort = new AbortController();
|
||||
}
|
||||
/** Cancel active writes only. Cancellation is not proof of noncommit, so the
|
||||
* receipt and draft are retained and the next action must be status lookup.
|
||||
* Read/preview/search signals and selection identity are left untouched.
|
||||
*/
|
||||
cancelPending(): void {
|
||||
if (this.disposed) return;
|
||||
if (
|
||||
!this.currentReceiptsForSelection().some((r) => r.state === "pending")
|
||||
) return;
|
||||
this.cancelWrites(
|
||||
"Transfer cancelled; outcome unknown; check request status",
|
||||
);
|
||||
const s = this.selection();
|
||||
this.publish({ receipts: this.currentReceipts(s.key) });
|
||||
}
|
||||
private currentReceiptsForSelection(): DriveReceipt[] {
|
||||
const { workspaceId, entry } = this.value;
|
||||
return workspaceId && entry
|
||||
? this.currentReceipts(key(workspaceId, entry.entry))
|
||||
: [];
|
||||
}
|
||||
private fence(): void {
|
||||
// Late promises must never publish into a reselected identity, even A -> B -> A.
|
||||
++this.generation;
|
||||
++this.pageGeneration;
|
||||
this.cancelWrites("Selection changed; check request status");
|
||||
this.abort.abort();
|
||||
this.abort = new AbortController();
|
||||
}
|
||||
async select(workspaceId: string | null, ref?: DriveEntryRef): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (ref && workspaceId) parseDriveEntryRef(ref, workspaceId);
|
||||
this.fence();
|
||||
const generation = this.generation;
|
||||
const pageGeneration = this.pageGeneration;
|
||||
const signal = this.abort.signal;
|
||||
this.publish({ ...empty(), workspaceId, loading: workspaceId !== null });
|
||||
if (!workspaceId) return;
|
||||
try {
|
||||
const entry = ref
|
||||
? await this.client.metadata(workspaceId, ref, signal)
|
||||
: await this.client.root(workspaceId, signal);
|
||||
if (!this.current(generation)) return;
|
||||
const k = key(workspaceId, entry.entry);
|
||||
this.publish({
|
||||
entry,
|
||||
draft: isText(entry) ? this.drafts.get(k) ?? null : null,
|
||||
receipts: this.currentReceipts(k),
|
||||
});
|
||||
if (entry.kind === "folder") {
|
||||
const result = await this.client.list(
|
||||
workspaceId,
|
||||
entry.entry,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
!this.current(generation) || pageGeneration !== this.pageGeneration
|
||||
) return;
|
||||
this.publish({ entries: result.entries, nextAfter: result.next_after });
|
||||
} else if (isText(entry)) {
|
||||
const result = await this.client.readText(
|
||||
workspaceId,
|
||||
entry.entry,
|
||||
signal,
|
||||
);
|
||||
if (!this.current(generation)) return;
|
||||
let draft = this.drafts.get(k) ?? null;
|
||||
// Clean acknowledged drafts may refresh, but dirty/conflicted drafts
|
||||
// and unresolved requests must keep their original CAS revision.
|
||||
const unresolved = this.currentReceipts(k).some((receipt) =>
|
||||
receipt.state === "pending" || receipt.state === "unknown"
|
||||
);
|
||||
const refreshable = !draft ||
|
||||
(draft.text === draft.baseText && !draft.conflict && !unresolved);
|
||||
if (refreshable && !unresolved) {
|
||||
draft = null;
|
||||
this.drafts.delete(k);
|
||||
}
|
||||
if (
|
||||
!draft && !unresolved && isText(result.entry) && !result.truncated &&
|
||||
(result.entry.size ?? Infinity) <= DRIVE_TEXT_MAX_BYTES
|
||||
) {
|
||||
draft = {
|
||||
text: result.text,
|
||||
baseText: result.text,
|
||||
expectedRevision: result.entry.revision,
|
||||
contentType: result.entry.content_type ?? "text/plain",
|
||||
conflict: false,
|
||||
};
|
||||
this.drafts.set(k, draft);
|
||||
}
|
||||
this.publish({
|
||||
entry: result.entry,
|
||||
text: isText(result.entry) ? result.text : null,
|
||||
truncated: result.truncated,
|
||||
draft: isText(result.entry) ? draft : null,
|
||||
});
|
||||
} else if (
|
||||
isImage(entry) && (entry.size ?? Infinity) <= DRIVE_RESPONSE_MAX_BYTES
|
||||
) {
|
||||
const image = await this.client.image(workspaceId, entry, signal);
|
||||
if (!this.current(generation)) return;
|
||||
this.publish({ image });
|
||||
}
|
||||
if (this.current(generation) && pageGeneration === this.pageGeneration) {
|
||||
this.publish({ loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.current(generation) && pageGeneration === this.pageGeneration) {
|
||||
this.publish({ loading: false, error: message(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
async refresh(): Promise<void> {
|
||||
const s = this.selection();
|
||||
await this.select(s.ws, s.entry.entry);
|
||||
}
|
||||
async search(query: string, includeText = false): Promise<void> {
|
||||
const s = this.selection();
|
||||
const pageGeneration = ++this.pageGeneration;
|
||||
this.publish({
|
||||
search: { query, includeText },
|
||||
entries: [],
|
||||
nextAfter: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
try {
|
||||
const result = await this.client.search(
|
||||
s.ws,
|
||||
query,
|
||||
includeText,
|
||||
{},
|
||||
s.signal,
|
||||
);
|
||||
if (
|
||||
!this.current(s.generation) || pageGeneration !== this.pageGeneration
|
||||
) return;
|
||||
this.publish({
|
||||
entries: result.entries,
|
||||
nextAfter: result.next_after,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
this.current(s.generation) && pageGeneration === this.pageGeneration
|
||||
) this.publish({ loading: false, error: message(error) });
|
||||
}
|
||||
}
|
||||
async loadMore(): Promise<void> {
|
||||
const s = this.selection();
|
||||
const after = this.value.nextAfter;
|
||||
if (!after || this.value.loading) return;
|
||||
const search = this.value.search;
|
||||
const pageGeneration = ++this.pageGeneration;
|
||||
this.publish({ loading: true, error: null });
|
||||
try {
|
||||
const result = search
|
||||
? await this.client.search(s.ws, search.query, search.includeText, {
|
||||
after,
|
||||
}, s.signal)
|
||||
: await this.client.list(s.ws, s.entry.entry, { after }, s.signal);
|
||||
if (
|
||||
!this.current(s.generation) || pageGeneration !== this.pageGeneration
|
||||
) return;
|
||||
const entries = new Map(
|
||||
this.value.entries.map((entry) => [entry.entry.node_id, entry]),
|
||||
);
|
||||
for (const entry of result.entries) {
|
||||
entries.set(entry.entry.node_id, entry);
|
||||
}
|
||||
this.publish({
|
||||
entries: [...entries.values()],
|
||||
nextAfter: result.next_after,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
this.current(s.generation) && pageGeneration === this.pageGeneration
|
||||
) this.publish({ loading: false, error: message(error) });
|
||||
}
|
||||
}
|
||||
edit(text: string): void {
|
||||
const s = this.selection();
|
||||
const existing = this.drafts.get(s.key);
|
||||
if (!existing || !isText(s.entry)) {
|
||||
throw new Error("This file has no editable bounded text draft");
|
||||
}
|
||||
const draft = { ...existing, text };
|
||||
this.drafts.set(s.key, draft);
|
||||
this.publish({ draft });
|
||||
}
|
||||
/** Explicit user discard/reload, never performed as conflict recovery. */
|
||||
async discardDraft(): Promise<void> {
|
||||
const s = this.selection();
|
||||
if (
|
||||
this.currentReceipts(s.key).some((r) =>
|
||||
r.state === "unknown" || r.state === "pending"
|
||||
)
|
||||
) throw new Error("Reconcile the pending request before discarding");
|
||||
this.drafts.delete(s.key);
|
||||
await this.refresh();
|
||||
}
|
||||
async save(): Promise<string> {
|
||||
const s = this.selection();
|
||||
const draft = this.drafts.get(s.key);
|
||||
if (!draft || !isText(s.entry)) throw new Error("No editable draft");
|
||||
const signal = this.writeAbort.signal;
|
||||
return await this.write(
|
||||
"update_text",
|
||||
(id) =>
|
||||
this.client.mutate(s.ws, id, {
|
||||
operation: "update_text",
|
||||
id: s.entry.entry,
|
||||
expected_revision: draft.expectedRevision,
|
||||
text: draft.text,
|
||||
content_type: draft.contentType,
|
||||
}, signal),
|
||||
{ text: draft.text, revision: draft.expectedRevision },
|
||||
);
|
||||
}
|
||||
async mutate(mutation: DriveMutation): Promise<string> {
|
||||
const s = this.selection();
|
||||
const signal = this.writeAbort.signal;
|
||||
return await this.write(
|
||||
mutation.operation,
|
||||
(id) => this.client.mutate(s.ws, id, mutation, signal),
|
||||
);
|
||||
}
|
||||
async upload(blob: Blob, target: DriveUploadTarget): Promise<string> {
|
||||
const s = this.selection();
|
||||
const signal = this.writeAbort.signal;
|
||||
return await this.write(
|
||||
"upload",
|
||||
(id) => this.client.upload(s.ws, id, blob, target, signal),
|
||||
);
|
||||
}
|
||||
private async write(
|
||||
operation: DriveReceipt["operation"],
|
||||
send: (id: string) => Promise<DriveMutationResponse>,
|
||||
saved?: ReceiptRecord["saved"],
|
||||
): Promise<string> {
|
||||
const s = this.selection();
|
||||
const writeGeneration = this.writeGeneration;
|
||||
const active = () =>
|
||||
this.current(s.generation) && writeGeneration === this.writeGeneration;
|
||||
if (
|
||||
this.currentReceipts(s.key).some((r) =>
|
||||
r.state === "pending" || r.state === "unknown"
|
||||
)
|
||||
) throw new Error("Reconcile the existing request; do not submit again");
|
||||
const id = this.newRequestId();
|
||||
const records = this.receipts.get(s.key) ??
|
||||
new Map<string, ReceiptRecord>();
|
||||
if (records.has(id)) throw new Error("Request ID must be unique");
|
||||
const record: ReceiptRecord = {
|
||||
receipt: {
|
||||
requestId: id,
|
||||
operation,
|
||||
state: "pending",
|
||||
error: null,
|
||||
response: null,
|
||||
},
|
||||
saved,
|
||||
};
|
||||
records.set(id, record);
|
||||
this.receipts.set(s.key, records);
|
||||
this.publish({ receipts: this.currentReceipts(s.key), error: null });
|
||||
try {
|
||||
const response = await send(id);
|
||||
if (!active()) return id;
|
||||
this.committed(s.key, record, response);
|
||||
} catch (error) {
|
||||
if (!active()) return id;
|
||||
// Non-typed errors include preflight validation (no transmission). The real
|
||||
// client wraps every failure after fetch as typed unknown.
|
||||
const unknown = error instanceof DriveRequestError &&
|
||||
error.classification === "unknown";
|
||||
record.receipt = {
|
||||
...record.receipt,
|
||||
state: unknown ? "unknown" : "not_committed",
|
||||
error: message(error),
|
||||
};
|
||||
if (error instanceof DriveRequestError && error.code === "conflict") {
|
||||
const draft = this.drafts.get(s.key);
|
||||
if (draft) {
|
||||
const conflict = { ...draft, conflict: true };
|
||||
this.drafts.set(s.key, conflict);
|
||||
this.publish({ draft: conflict });
|
||||
}
|
||||
}
|
||||
this.publish({
|
||||
receipts: this.currentReceipts(s.key),
|
||||
error: message(error),
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
private committed(
|
||||
k: string,
|
||||
record: ReceiptRecord,
|
||||
response: DriveMutationResponse,
|
||||
): void {
|
||||
record.receipt = {
|
||||
...record.receipt,
|
||||
state: "committed",
|
||||
response,
|
||||
error: null,
|
||||
};
|
||||
const entry = response.entry;
|
||||
const draft = this.drafts.get(k);
|
||||
if (
|
||||
record.saved && draft && entry &&
|
||||
draft.expectedRevision === record.saved.revision &&
|
||||
entry.entry.node_id === this.value.entry?.entry.node_id
|
||||
) {
|
||||
const updated = {
|
||||
...draft,
|
||||
baseText: record.saved.text,
|
||||
expectedRevision: entry.revision,
|
||||
conflict: false,
|
||||
};
|
||||
this.drafts.set(k, updated);
|
||||
this.publish({ draft: updated, entry, text: record.saved.text });
|
||||
} else if (
|
||||
entry && entry.entry.node_id === this.value.entry?.entry.node_id
|
||||
) this.publish({ entry });
|
||||
this.publish({ receipts: this.currentReceipts(k), error: null });
|
||||
}
|
||||
async reconcile(requestId: string): Promise<void> {
|
||||
const s = this.selection();
|
||||
const record = this.receipts.get(s.key)?.get(requestId);
|
||||
if (!record || record.receipt.state !== "unknown") {
|
||||
throw new Error("No unknown request to reconcile");
|
||||
}
|
||||
try {
|
||||
const status = await this.client.status(
|
||||
s.ws,
|
||||
requestId,
|
||||
this.writeAbort.signal,
|
||||
);
|
||||
if (!this.current(s.generation)) return;
|
||||
if (status.state === "committed" && status.response) {
|
||||
this.committed(s.key, record, status.response);
|
||||
} else {
|
||||
record.receipt = {
|
||||
...record.receipt,
|
||||
error: "No commit observed yet; outcome remains unknown",
|
||||
};
|
||||
this.publish({ receipts: this.currentReceipts(s.key) });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.current(s.generation)) return;
|
||||
record.receipt = { ...record.receipt, error: message(error) };
|
||||
this.publish({ receipts: this.currentReceipts(s.key) });
|
||||
}
|
||||
}
|
||||
dispose(): void {
|
||||
this.fence();
|
||||
this.disposed = true;
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
export function isText(entry: DriveEntry): boolean {
|
||||
return entry.kind === "file" &&
|
||||
["text/plain", "text/markdown"].includes(entry.content_type ?? "");
|
||||
}
|
||||
export function isImage(entry: DriveEntry): boolean {
|
||||
return entry.kind === "file" &&
|
||||
/^image\/(png|jpeg|gif|webp)$/.test(entry.content_type ?? "");
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
import type {
|
||||
DriveMutationResponse,
|
||||
DriveReadTextResponse,
|
||||
DriveRequestStatusResponse,
|
||||
} from "#lib/generated/drive-api.ts";
|
||||
import { type DriveClient, DriveRequestError } from "./api.ts";
|
||||
import { DriveController } from "./controller.svelte.ts";
|
||||
import {
|
||||
assert,
|
||||
deferred,
|
||||
entry,
|
||||
equal,
|
||||
ref,
|
||||
rejects,
|
||||
throws,
|
||||
} from "./test-fixtures.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
function client(patch: Partial<DriveClient> = {}): DriveClient {
|
||||
return {
|
||||
root: (ws) => Promise.resolve(entry("1", ws, "1", "folder")),
|
||||
metadata: (ws, target) =>
|
||||
Promise.resolve(
|
||||
entry(
|
||||
target.node_id,
|
||||
ws,
|
||||
"9007199254740993",
|
||||
target.node_id === "1" ? "folder" : "file",
|
||||
),
|
||||
),
|
||||
list: () => Promise.resolve({ entries: [], next_after: null }),
|
||||
search: () => Promise.resolve({ entries: [], next_after: null }),
|
||||
readText: (ws, target) =>
|
||||
Promise.resolve({
|
||||
entry: entry(target.node_id, ws),
|
||||
text: "abc",
|
||||
truncated: false,
|
||||
}),
|
||||
image: () => Promise.resolve(new Blob(["abc"])),
|
||||
mutate: (ws, id) =>
|
||||
Promise.resolve({
|
||||
request_id: id,
|
||||
entry: entry("2", ws, "9007199254740994"),
|
||||
}),
|
||||
upload: (ws, id) =>
|
||||
Promise.resolve({
|
||||
request_id: id,
|
||||
entry: entry("2", ws, "9007199254740994"),
|
||||
}),
|
||||
status: (_, id) =>
|
||||
Promise.resolve({ request_id: id, state: "uncommitted", response: null }),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
function controller(patch: Partial<DriveClient> = {}) {
|
||||
let n = 0;
|
||||
return new DriveController(client(patch), () => `req-${++n}`);
|
||||
}
|
||||
Deno.test("Drive delayed read cannot replace workspace with the same numeric node id", async () => {
|
||||
const old = deferred<DriveReadTextResponse>();
|
||||
const started = deferred<void>();
|
||||
const c = controller({
|
||||
readText: (ws, target) => {
|
||||
if (ws === "alpha") {
|
||||
started.resolve();
|
||||
return old.promise;
|
||||
}
|
||||
return Promise.resolve({
|
||||
entry: entry(target.node_id, ws),
|
||||
text: "beta",
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = c.select("alpha", ref());
|
||||
await started.promise;
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
old.resolve({ entry: entry(), text: "old", truncated: false });
|
||||
await first;
|
||||
equal(c.state.workspaceId, "beta");
|
||||
equal(c.state.text, "beta");
|
||||
equal(c.state.draft?.text, "beta");
|
||||
});
|
||||
Deno.test("Drive A to B to A selection rejects the first A response and late read errors", async () => {
|
||||
const old = deferred<DriveReadTextResponse>();
|
||||
const started = deferred<void>();
|
||||
let reads = 0;
|
||||
const c = controller({
|
||||
readText: (ws, target) => {
|
||||
if (++reads === 1) {
|
||||
started.resolve();
|
||||
return old.promise;
|
||||
}
|
||||
return Promise.resolve({
|
||||
entry: entry(target.node_id, ws),
|
||||
text: "fresh",
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = c.select("alpha", ref());
|
||||
await started.promise;
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
old.reject(new Error("late"));
|
||||
await first;
|
||||
equal(c.state.entry?.entry.node_id, "2");
|
||||
equal(c.state.text, "fresh");
|
||||
equal(c.state.error, null);
|
||||
});
|
||||
Deno.test("Drive drafts survive workspace and node switches without revision rebasing", async () => {
|
||||
let revision = "9007199254740993";
|
||||
const c = controller({
|
||||
readText: (ws, target) =>
|
||||
Promise.resolve({
|
||||
entry: entry(target.node_id, ws, revision),
|
||||
text: "server",
|
||||
truncated: false,
|
||||
}),
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("local alpha");
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
c.edit("local beta");
|
||||
revision = "9007199254740999";
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.draft?.text, "local alpha");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.entry?.revision, "9007199254740999");
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
equal(c.state.draft?.text, "local beta");
|
||||
});
|
||||
Deno.test("Drive conflict keeps local text and old CAS revision across switching without retry", async () => {
|
||||
let mutations = 0;
|
||||
const c = controller({
|
||||
mutate: (_, __, mutation) => {
|
||||
++mutations;
|
||||
assert("expected_revision" in mutation);
|
||||
equal(mutation.expected_revision, "9007199254740993");
|
||||
return Promise.reject(new DriveRequestError("conflict", "not_committed"));
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("local");
|
||||
await c.save();
|
||||
equal(c.state.draft?.conflict, true);
|
||||
equal(c.state.draft?.text, "local");
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.draft?.conflict, true);
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(mutations, 1);
|
||||
});
|
||||
Deno.test("Drive acknowledged save preserves edits made while the request was in flight", async () => {
|
||||
const pending = deferred<DriveMutationResponse>();
|
||||
const c = controller({ mutate: () => pending.promise });
|
||||
await c.select("alpha", ref());
|
||||
c.edit("sent");
|
||||
const saving = c.save();
|
||||
c.edit("new edit");
|
||||
pending.resolve({
|
||||
request_id: "req-1",
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
});
|
||||
await saving;
|
||||
equal(c.state.draft?.text, "new edit");
|
||||
equal(c.state.draft?.baseText, "sent");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
});
|
||||
Deno.test("Drive late mutation and upload success cannot affect a newly selected identity", async () => {
|
||||
for (const operation of ["mutation", "upload"] as const) {
|
||||
const pending = deferred<DriveMutationResponse>();
|
||||
const c = controller({
|
||||
mutate: () => pending.promise,
|
||||
upload: () => pending.promise,
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("sent");
|
||||
const sending = operation === "mutation"
|
||||
? c.save()
|
||||
: c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
pending.resolve({
|
||||
request_id: "req-1",
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
});
|
||||
await sending;
|
||||
equal(c.state.workspaceId, "beta");
|
||||
equal(c.state.receipts, []);
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.text, "sent");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
}
|
||||
});
|
||||
Deno.test("Drive unknown upload reconciles by request id and never blindly retransmits", async () => {
|
||||
let uploads = 0;
|
||||
let statuses = 0;
|
||||
let committed = false;
|
||||
const c = controller({
|
||||
upload: () => {
|
||||
++uploads;
|
||||
return Promise.reject(
|
||||
new DriveRequestError("outcome_unknown", "unknown"),
|
||||
);
|
||||
},
|
||||
status: (_, id) => {
|
||||
++statuses;
|
||||
equal(id, "req-1");
|
||||
return Promise.resolve(
|
||||
committed
|
||||
? {
|
||||
request_id: id,
|
||||
state: "committed",
|
||||
response: {
|
||||
request_id: id,
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
},
|
||||
}
|
||||
: { request_id: id, state: "uncommitted", response: null },
|
||||
);
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
const target = {
|
||||
operation: "update" as const,
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
content_type: "application/octet-stream",
|
||||
};
|
||||
const id = await c.upload(new Blob(["abc"]), target);
|
||||
equal(id, "req-1");
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
await c.reconcile(id);
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
await rejects(() => c.upload(new Blob(["abc"]), target));
|
||||
equal(uploads, 1);
|
||||
committed = true;
|
||||
await c.reconcile(id);
|
||||
equal(c.state.receipts[0].state, "committed");
|
||||
equal(uploads, 1);
|
||||
equal(statuses, 2);
|
||||
});
|
||||
Deno.test("Drive late status response is fenced even when the original node is reselected", async () => {
|
||||
const status = deferred<DriveRequestStatusResponse>();
|
||||
const c = controller({
|
||||
mutate: () =>
|
||||
Promise.reject(new DriveRequestError("outcome_unknown", "unknown")),
|
||||
status: () => status.promise,
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("local");
|
||||
const id = await c.save();
|
||||
const checking = c.reconcile(id);
|
||||
await c.select("alpha", ref("3"));
|
||||
await c.select("alpha", ref());
|
||||
status.resolve({
|
||||
request_id: id,
|
||||
state: "committed",
|
||||
response: {
|
||||
request_id: id,
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
},
|
||||
});
|
||||
await checking;
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.text, "local");
|
||||
});
|
||||
Deno.test("Drive truncated text has no editable draft and disposal rejects late publication", async () => {
|
||||
const c = controller({
|
||||
readText: () =>
|
||||
Promise.resolve({ entry: entry(), text: "abc", truncated: true }),
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
equal(c.state.draft, null);
|
||||
await rejects(() => c.save());
|
||||
const late = deferred<DriveReadTextResponse>();
|
||||
const started = deferred<void>();
|
||||
const disposed = controller({
|
||||
readText: () => {
|
||||
started.resolve();
|
||||
return late.promise;
|
||||
},
|
||||
});
|
||||
let notifications = 0;
|
||||
disposed.subscribe(() => ++notifications);
|
||||
const selection = disposed.select("alpha", ref());
|
||||
await started.promise;
|
||||
disposed.dispose();
|
||||
const before = notifications;
|
||||
late.resolve({ entry: entry(), text: "late", truncated: false });
|
||||
await selection;
|
||||
equal(notifications, before);
|
||||
equal(disposed.state.text, null);
|
||||
});
|
||||
Deno.test("Drive search and pagination ignore results from the previous identity", async () => {
|
||||
const search = deferred<
|
||||
{ entries: ReturnType<typeof entry>[]; next_after: string | null }
|
||||
>();
|
||||
const c = controller({ search: () => search.promise });
|
||||
await c.select("alpha", ref("1"));
|
||||
const searching = c.search("old");
|
||||
await c.select("beta", ref("1", "beta"));
|
||||
search.resolve({ entries: [entry()], next_after: "alpha:2" });
|
||||
await searching;
|
||||
equal(c.state.entries, []);
|
||||
equal(c.state.search, null);
|
||||
equal(c.state.nextAfter, null);
|
||||
});
|
||||
Deno.test("Drive late image preview does not publish into another workspace", async () => {
|
||||
const image = deferred<Blob>();
|
||||
const started = deferred<void>();
|
||||
const c = controller({
|
||||
metadata: (ws, target) =>
|
||||
Promise.resolve({
|
||||
...entry(target.node_id, ws),
|
||||
content_type: "image/png",
|
||||
}),
|
||||
image: (ws) => {
|
||||
if (ws === "alpha") {
|
||||
started.resolve();
|
||||
return image.promise;
|
||||
}
|
||||
return Promise.resolve(new Blob(["new"]));
|
||||
},
|
||||
});
|
||||
const selecting = c.select("alpha", ref());
|
||||
await started.promise;
|
||||
await c.select("beta", ref("2", "beta"));
|
||||
const current = c.state.image;
|
||||
image.resolve(new Blob(["old"]));
|
||||
await selecting;
|
||||
assert(c.state.image === current);
|
||||
});
|
||||
Deno.test("Drive delayed initial list cannot overwrite a newer search in the same folder", async () => {
|
||||
const list = deferred<
|
||||
{ entries: ReturnType<typeof entry>[]; next_after: string | null }
|
||||
>();
|
||||
const started = deferred<void>();
|
||||
const c = controller({
|
||||
list: () => {
|
||||
started.resolve();
|
||||
return list.promise;
|
||||
},
|
||||
search: () =>
|
||||
Promise.resolve({ entries: [entry("4")], next_after: "alpha:4" }),
|
||||
});
|
||||
const selecting = c.select("alpha", ref("1"));
|
||||
await started.promise;
|
||||
await c.search("new");
|
||||
list.resolve({ entries: [entry("2")], next_after: "alpha:2" });
|
||||
await selecting;
|
||||
equal(c.state.entries.map((e) => e.entry.node_id), ["4"]);
|
||||
equal(c.state.nextAfter, "alpha:4");
|
||||
equal(c.state.search?.query, "new");
|
||||
});
|
||||
Deno.test("Drive delayed root and metadata are fenced before publishing selection", async () => {
|
||||
for (const kind of ["root", "metadata"] as const) {
|
||||
const old = deferred<ReturnType<typeof entry>>();
|
||||
const c = controller({
|
||||
[kind]: (ws: string) =>
|
||||
ws === "alpha"
|
||||
? old.promise
|
||||
: Promise.resolve(entry("1", ws, "1", "folder")),
|
||||
});
|
||||
const selecting = c.select(
|
||||
"alpha",
|
||||
kind === "metadata" ? ref("1") : undefined,
|
||||
);
|
||||
await c.select("beta", ref("1", "beta"));
|
||||
old.resolve(entry("1", "alpha", "1", "folder"));
|
||||
await selecting;
|
||||
equal(c.state.entry?.entry.workspace_id, "beta");
|
||||
equal(c.state.error, null);
|
||||
}
|
||||
});
|
||||
Deno.test("Drive pagination completion cannot publish after workspace switch", async () => {
|
||||
const late = deferred<
|
||||
{ entries: ReturnType<typeof entry>[]; next_after: string | null }
|
||||
>();
|
||||
const c = controller({
|
||||
list: (ws, _, options) =>
|
||||
options?.after ? late.promise : Promise.resolve({
|
||||
entries: [],
|
||||
next_after: ws === "alpha" ? "alpha:2" : null,
|
||||
}),
|
||||
});
|
||||
await c.select("alpha", ref("1"));
|
||||
const loading = c.loadMore();
|
||||
await c.select("beta", ref("1", "beta"));
|
||||
late.resolve({ entries: [entry()], next_after: "alpha:3" });
|
||||
await loading;
|
||||
equal(c.state.entries, []);
|
||||
equal(c.state.nextAfter, null);
|
||||
equal(c.state.loading, false);
|
||||
});
|
||||
Deno.test("Drive late mutation conflict cannot mark the new node's draft", async () => {
|
||||
const late = deferred<DriveMutationResponse>();
|
||||
const c = controller({ mutate: () => late.promise });
|
||||
await c.select("alpha", ref());
|
||||
const saving = c.save();
|
||||
await c.select("alpha", ref("3"));
|
||||
late.reject(new DriveRequestError("conflict", "not_committed"));
|
||||
await saving;
|
||||
equal(c.state.draft?.conflict, false);
|
||||
equal(c.state.error, null);
|
||||
});
|
||||
Deno.test("Drive cancelled writes retain draft and receipt and ignore late completion after committed status", async () => {
|
||||
for (const operation of ["save", "upload"] as const) {
|
||||
const pending = deferred<DriveMutationResponse>();
|
||||
let writeSignal: AbortSignal | undefined;
|
||||
let statusSignal: AbortSignal | undefined;
|
||||
let statuses = 0;
|
||||
let sends = 0;
|
||||
const send = (
|
||||
_ws: string,
|
||||
_id: string,
|
||||
_body: unknown,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
++sends;
|
||||
writeSignal = signal;
|
||||
return pending.promise;
|
||||
};
|
||||
const c = controller({
|
||||
mutate: send,
|
||||
upload: (_ws, _id, _blob, _target, signal) =>
|
||||
send(_ws, _id, _blob, signal),
|
||||
status: (_ws, id, signal) => {
|
||||
statusSignal = signal;
|
||||
++statuses;
|
||||
return Promise.resolve(
|
||||
statuses === 1
|
||||
? { request_id: id, state: "uncommitted", response: null }
|
||||
: {
|
||||
request_id: id,
|
||||
state: "committed",
|
||||
response: {
|
||||
request_id: id,
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("sent draft");
|
||||
const sending = operation === "save"
|
||||
? c.save()
|
||||
: c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
equal(c.state.receipts[0].state, "pending");
|
||||
const id = c.state.receipts[0].requestId;
|
||||
c.cancelPending();
|
||||
assert(writeSignal?.aborted);
|
||||
equal(c.state.receipts[0].requestId, id);
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.text, "sent draft");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
await rejects(() => c.save());
|
||||
await c.reconcile(id);
|
||||
assert(
|
||||
statusSignal && !statusSignal.aborted && statusSignal !== writeSignal,
|
||||
);
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
await rejects(() =>
|
||||
c.upload(new Blob(["abc"]), {
|
||||
operation: "create",
|
||||
parent: ref("1"),
|
||||
name: "note.txt",
|
||||
content_type: "text/plain",
|
||||
})
|
||||
);
|
||||
await c.reconcile(id);
|
||||
equal(c.state.receipts[0].state, "committed");
|
||||
const snapshot = c.state;
|
||||
pending.resolve({
|
||||
request_id: id,
|
||||
entry: entry("2", "alpha", "9007199254740999"),
|
||||
});
|
||||
equal(await sending, id);
|
||||
assert(c.state === snapshot);
|
||||
equal(sends, 1);
|
||||
equal(c.state.entry?.revision, "9007199254740994");
|
||||
}
|
||||
});
|
||||
Deno.test("Drive cancellation does not abort or fence active search reads", async () => {
|
||||
const search = deferred<
|
||||
{ entries: ReturnType<typeof entry>[]; next_after: string | null }
|
||||
>();
|
||||
const upload = deferred<DriveMutationResponse>();
|
||||
let readSignal: AbortSignal | undefined;
|
||||
let writeSignal: AbortSignal | undefined;
|
||||
const c = controller({
|
||||
search: (_ws, _text, _include, _options, signal) => {
|
||||
readSignal = signal;
|
||||
return search.promise;
|
||||
},
|
||||
upload: (_ws, _id, _blob, _target, signal) => {
|
||||
writeSignal = signal;
|
||||
return upload.promise;
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref("1"));
|
||||
const searching = c.search("read still active");
|
||||
const uploading = c.upload(new Blob(["abc"]), {
|
||||
operation: "create",
|
||||
parent: ref("1"),
|
||||
name: "note.txt",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
c.cancelPending();
|
||||
assert(writeSignal?.aborted);
|
||||
assert(readSignal && !readSignal.aborted);
|
||||
search.resolve({ entries: [entry("3")], next_after: null });
|
||||
await searching;
|
||||
equal(c.state.entries[0].entry.node_id, "3");
|
||||
equal(c.state.loading, false);
|
||||
upload.reject(new DriveRequestError("conflict", "not_committed"));
|
||||
await uploading;
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.error, null);
|
||||
});
|
||||
Deno.test("Drive concurrent writes are blocked before transmission and receipt creation", async () => {
|
||||
const pending = deferred<DriveMutationResponse>();
|
||||
let sends = 0;
|
||||
const c = controller({
|
||||
mutate: () => {
|
||||
++sends;
|
||||
return pending.promise;
|
||||
},
|
||||
upload: () => {
|
||||
++sends;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
const saving = c.save();
|
||||
await rejects(() => c.save());
|
||||
await rejects(() =>
|
||||
c.mutate({
|
||||
operation: "delete",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
})
|
||||
);
|
||||
await rejects(() =>
|
||||
c.upload(new Blob(["abc"]), {
|
||||
operation: "update",
|
||||
id: ref(),
|
||||
expected_revision: "9007199254740993",
|
||||
content_type: "text/plain",
|
||||
})
|
||||
);
|
||||
equal(sends, 1);
|
||||
equal(c.state.receipts.length, 1);
|
||||
pending.resolve({
|
||||
request_id: "req-1",
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
});
|
||||
await saving;
|
||||
});
|
||||
Deno.test("Drive clean draft refresh adopts latest revision unless a request remains unresolved", async () => {
|
||||
let revision = "9007199254740993";
|
||||
let text = "abc";
|
||||
const c = controller({
|
||||
readText: () =>
|
||||
Promise.resolve({
|
||||
entry: entry("2", "alpha", revision),
|
||||
text,
|
||||
truncated: false,
|
||||
}),
|
||||
mutate: () =>
|
||||
Promise.reject(new DriveRequestError("outcome_unknown", "unknown")),
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
revision = "9007199254740994";
|
||||
text = "new";
|
||||
await c.refresh();
|
||||
equal(c.state.draft?.text, "new");
|
||||
equal(c.state.draft?.baseText, "new");
|
||||
equal(c.state.draft?.expectedRevision, revision);
|
||||
await c.save();
|
||||
revision = "9007199254740995";
|
||||
text = "unknown change";
|
||||
await c.refresh();
|
||||
equal(c.state.text, "unknown change");
|
||||
equal(c.state.draft?.text, "new");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
await rejects(() => c.save());
|
||||
});
|
||||
Deno.test("Drive previews allow plain text markdown and four raster types only", async () => {
|
||||
for (
|
||||
const contentType of [
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"text/xml",
|
||||
"text/css",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"image/svg+xml",
|
||||
"image/avif",
|
||||
]
|
||||
) {
|
||||
let textReads = 0;
|
||||
let imageReads = 0;
|
||||
const c = controller({
|
||||
metadata: () =>
|
||||
Promise.resolve({ ...entry(), content_type: contentType }),
|
||||
readText: () => {
|
||||
++textReads;
|
||||
return Promise.resolve({
|
||||
entry: { ...entry(), content_type: contentType },
|
||||
text: "abc",
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
image: () => {
|
||||
++imageReads;
|
||||
return Promise.resolve(new Blob(["abc"]));
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
equal(
|
||||
textReads,
|
||||
["text/plain", "text/markdown"].includes(contentType) ? 1 : 0,
|
||||
);
|
||||
equal(
|
||||
imageReads,
|
||||
["image/png", "image/jpeg", "image/gif", "image/webp"].includes(
|
||||
contentType,
|
||||
)
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
if (textReads === 0) {
|
||||
equal(c.state.text, null);
|
||||
equal(c.state.draft, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
Deno.test("Drive cancelled late success stays unknown until explicit committed status lookup", async () => {
|
||||
const late = deferred<DriveMutationResponse>();
|
||||
let statusCalls = 0;
|
||||
const c = controller({
|
||||
mutate: () => late.promise,
|
||||
status: (_ws, id) => {
|
||||
++statusCalls;
|
||||
return Promise.resolve({
|
||||
request_id: id,
|
||||
state: "committed",
|
||||
response: {
|
||||
request_id: id,
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("local");
|
||||
const saving = c.save();
|
||||
c.cancelPending();
|
||||
late.resolve({
|
||||
request_id: "req-1",
|
||||
entry: entry("2", "alpha", "9007199254740994"),
|
||||
});
|
||||
const id = await saving;
|
||||
equal(c.state.receipts[0].state, "unknown");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
equal(c.state.draft?.text, "local");
|
||||
equal(statusCalls, 0);
|
||||
await rejects(() => c.save());
|
||||
await c.reconcile(id);
|
||||
equal(c.state.receipts[0].state, "committed");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740994");
|
||||
equal(c.state.draft?.baseText, "local");
|
||||
});
|
||||
Deno.test("Drive archived dirty draft cannot enable editing after MIME changes to active bytes", async () => {
|
||||
let contentType = "text/plain";
|
||||
let textReads = 0;
|
||||
const c = controller({
|
||||
metadata: () => Promise.resolve({ ...entry(), content_type: contentType }),
|
||||
readText: () => {
|
||||
++textReads;
|
||||
return Promise.resolve({
|
||||
entry: { ...entry(), content_type: contentType },
|
||||
text: "abc",
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
await c.select("alpha", ref());
|
||||
c.edit("retain me");
|
||||
contentType = "text/html";
|
||||
await c.refresh();
|
||||
equal(textReads, 1);
|
||||
equal(c.state.draft, null);
|
||||
equal(c.state.text, null);
|
||||
throws(() => c.edit("unsafe edit"));
|
||||
await rejects(() => c.save());
|
||||
contentType = "text/plain";
|
||||
await c.refresh();
|
||||
equal(c.state.draft?.text, "retain me");
|
||||
equal(c.state.draft?.expectedRevision, "9007199254740993");
|
||||
});
|
||||
Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread removes editor", async () => {
|
||||
let revision = "9007199254740993";
|
||||
let truncated = false;
|
||||
const readText = () =>
|
||||
Promise.resolve({
|
||||
entry: entry("2", "alpha", revision),
|
||||
text: "abc",
|
||||
truncated,
|
||||
});
|
||||
const conflict = controller({
|
||||
readText,
|
||||
mutate: () =>
|
||||
Promise.reject(new DriveRequestError("conflict", "not_committed")),
|
||||
});
|
||||
await conflict.select("alpha", ref());
|
||||
await conflict.save();
|
||||
revision = "9007199254740994";
|
||||
await conflict.refresh();
|
||||
equal(conflict.state.draft?.text, "abc");
|
||||
equal(conflict.state.draft?.baseText, "abc");
|
||||
equal(conflict.state.draft?.conflict, true);
|
||||
equal(conflict.state.draft?.expectedRevision, "9007199254740993");
|
||||
const clean = controller({ readText });
|
||||
await clean.select("alpha", ref());
|
||||
assert(clean.state.draft);
|
||||
truncated = true;
|
||||
await clean.refresh();
|
||||
equal(clean.state.draft, null);
|
||||
await rejects(() => clean.save());
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DriveEntry, DriveEntryRef } from "./api.ts";
|
||||
export function assert(
|
||||
condition: unknown,
|
||||
message = "Assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
export function equal(actual: unknown, expected: unknown): void {
|
||||
assert(
|
||||
JSON.stringify(actual) === JSON.stringify(expected),
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
export function throws(fn: () => unknown): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
export async function rejects(
|
||||
fn: () => Promise<unknown>,
|
||||
check?: (error: unknown) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
check?.(error);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
export function ref(id = "2", ws = "alpha"): DriveEntryRef {
|
||||
return { workspace_id: ws, node_id: id };
|
||||
}
|
||||
export function entry(
|
||||
id = "2",
|
||||
ws = "alpha",
|
||||
revision = "9007199254740993",
|
||||
kind: "file" | "folder" = "file",
|
||||
): DriveEntry {
|
||||
return {
|
||||
entry: ref(id, ws),
|
||||
parent: id === "1" ? null : ref("1", ws),
|
||||
name: id === "1" ? "" : "note.txt",
|
||||
kind,
|
||||
revision,
|
||||
size: kind === "file" ? 3 : null,
|
||||
content_type: kind === "file" ? "text/plain" : null,
|
||||
updated_by: "user:test",
|
||||
updated_at: "2026-10-09T00:00:00Z",
|
||||
latest_url: `/api/w/${ws}/drive/${
|
||||
kind === "file" ? "download" : "metadata"
|
||||
}?entry_workspace_id=${ws}&id=${id}`,
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,13 @@
|
||||
import MarkdownTable from "./MarkdownTable.svelte";
|
||||
import SafeMarkdown from "./SafeMarkdown.svelte";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
let { text, imageRenderer }: { text: string; imageRenderer?: Renderers['image'] } = $props();
|
||||
|
||||
const renderers = {
|
||||
const renderers = $derived({
|
||||
code: DocumentCodeBlock,
|
||||
table: MarkdownTable,
|
||||
} satisfies Partial<Renderers>;
|
||||
...(imageRenderer ? { image: imageRenderer } : {}),
|
||||
} satisfies Partial<Renderers>);
|
||||
</script>
|
||||
|
||||
<SafeMarkdown {text} class="document-markdown" {renderers} />
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
href = "",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let safeHref = $derived(httpHref(href));
|
||||
|
||||
function httpHref(value: string): string | undefined {
|
||||
try {
|
||||
const protocol = new URL(value).protocol.toLowerCase();
|
||||
return protocol === "http:" || protocol === "https:" ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import { markdownLinkTarget, MARKDOWN_WORKSPACE_CONTEXT, type MarkdownWorkspace } from './link-policy.ts';
|
||||
const workspace = getContext<MarkdownWorkspace | undefined>(MARKDOWN_WORKSPACE_CONTEXT);
|
||||
let { href = '', title, children }: { href?: string; title?: string; children?: Snippet } = $props();
|
||||
let target = $derived(markdownLinkTarget(href, workspace?.()));
|
||||
</script>
|
||||
|
||||
{#if safeHref}
|
||||
<a href={safeHref} {title} target="_blank" rel="noreferrer">
|
||||
{#if target}
|
||||
<a href={target.href} {title} target={target.external ? '_blank' : undefined} rel={target.external ? 'noreferrer' : undefined}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Workspace resource links are navigation, never bearer credentials or fetch targets.
|
||||
export const MARKDOWN_WORKSPACE_CONTEXT = "yoi-markdown-workspace";
|
||||
export type MarkdownWorkspace = () => string;
|
||||
export type MarkdownLinkTarget = { href: string; external: boolean };
|
||||
|
||||
export function markdownLinkTarget(
|
||||
value: string,
|
||||
workspaceId?: string,
|
||||
): MarkdownLinkTarget | null {
|
||||
// Only explicit Workspace routes are accepted as relative links. Reject protocol-relative,
|
||||
// encoded separators, traversal, query strings and arbitrary app/API endpoints.
|
||||
if (value.startsWith("/w/") && !/[?#\\\s]/u.test(value)) {
|
||||
const match =
|
||||
/^\/w\/([^/]+)\/(drive|tickets|objectives|workers|memory)(?:\/([^/]+))?$/u
|
||||
.exec(value);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const workspace = decodeURIComponent(match[1]);
|
||||
const reference = match[3] ? decodeURIComponent(match[3]) : "";
|
||||
if (
|
||||
!workspace || /[/\\\s]/u.test(workspace) || /[/\\]/u.test(reference) ||
|
||||
reference === "." || reference === ".."
|
||||
) return null;
|
||||
if (workspaceId !== undefined && workspace !== workspaceId) return null;
|
||||
if (
|
||||
match[2] === "drive" && reference && !/^[1-9][0-9]*$/u.test(reference)
|
||||
) return null;
|
||||
return { href: value, external: false };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
!["http:", "https:"].includes(url.protocol) || url.username ||
|
||||
url.password
|
||||
) return null;
|
||||
return { href: value, external: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
import type { DriveEntryRef } from "#lib/generated/drive-api.ts";
|
||||
|
||||
/** Human-facing latest-only link: name, path, revision and bearer tokens are absent. */
|
||||
export function driveHref(entry: DriveEntryRef): string {
|
||||
if (
|
||||
!/^[1-9][0-9]{0,18}$/.test(entry.node_id) ||
|
||||
BigInt(entry.node_id) > 9223372036854775807n
|
||||
) {
|
||||
throw new Error("Invalid Drive node ID");
|
||||
}
|
||||
return `/w/${encodeURIComponent(entry.workspace_id)}/drive/${entry.node_id}`;
|
||||
}
|
||||
|
||||
const RESOURCE_KEY_PATTERN = /^(T|O|W)-(\d+)/;
|
||||
|
||||
export function resourceKey(reference: string): string {
|
||||
@@ -28,22 +41,35 @@ export function ticketHref(
|
||||
workspaceId: string,
|
||||
ticket: { resource_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.resource_key, ticket.title))}`;
|
||||
return `/w/${encodeURIComponent(workspaceId)}/tickets/${
|
||||
encodeURIComponent(
|
||||
canonicalResourceReference(ticket.resource_key, ticket.title),
|
||||
)
|
||||
}`;
|
||||
}
|
||||
|
||||
export function objectiveHref(
|
||||
workspaceId: string,
|
||||
objective: { resource_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.resource_key, objective.title))}`;
|
||||
return `/w/${encodeURIComponent(workspaceId)}/objectives/${
|
||||
encodeURIComponent(
|
||||
canonicalResourceReference(objective.resource_key, objective.title),
|
||||
)
|
||||
}`;
|
||||
}
|
||||
|
||||
export function workerHref(
|
||||
workspaceId: string,
|
||||
worker: { resource_key: string; display_name: string },
|
||||
): string {
|
||||
const reference = canonicalResourceReference(worker.resource_key, worker.display_name);
|
||||
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
|
||||
const reference = canonicalResourceReference(
|
||||
worker.resource_key,
|
||||
worker.display_name,
|
||||
);
|
||||
return `/w/${encodeURIComponent(workspaceId)}/workers/${
|
||||
encodeURIComponent(reference)
|
||||
}`;
|
||||
}
|
||||
|
||||
export function workerConsoleHref(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '#lib/workspace/api/http.ts';
|
||||
let { workspaceId, currentPath = '/' }: { workspaceId: string; currentPath?: string } = $props();
|
||||
let href = $derived(workspaceRoute(workspaceId, '/drive'));
|
||||
let active = $derived(currentPath === href || currentPath.startsWith(`${href}/`));
|
||||
</script>
|
||||
<section class="sidebar-nav-section sidebar-nav-section--category">
|
||||
<a class="sidebar-link" class:active {href} aria-current={active ? 'page' : undefined}>Drive</a>
|
||||
</section>
|
||||
@@ -8,6 +8,7 @@
|
||||
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
|
||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import DriveNavSection from './DriveNavSection.svelte';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
@@ -80,6 +81,7 @@
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<DriveNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import DrivePage from '#lib/workspace/drive-ui/DrivePage.svelte';
|
||||
</script>
|
||||
<svelte:head><title>Drive · Yoi Workspace</title><meta name="description" content="Workspace shared files" /></svelte:head>
|
||||
<DrivePage workspaceId={page.params.workspaceId ?? ''} nodeId={page.params.nodeId} />
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import WorkerDriveGrants from '#lib/workspace/drive-grants/WorkerDriveGrants.svelte';
|
||||
import { canRestoreWorker, createRestoreRequest, restoreWorkspaceWorker, restoreNotice, restoreErrorNotice, type RestoreRequest } from '#lib/workspace/sidebar/worker-actions.ts';
|
||||
import { workspaceWorkersStore, refreshWorkspaceWorkers } from '#lib/workspace/sidebar/worker-subscription.ts';
|
||||
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||
@@ -27,7 +28,7 @@
|
||||
|
||||
let lifetime = 0;
|
||||
let cleanupEpoch = 0;
|
||||
let catalogRefreshing = true;
|
||||
let catalogRefreshing = $state(true);
|
||||
let catalogReady = $state(false);
|
||||
|
||||
function actionScope() {
|
||||
@@ -286,7 +287,8 @@
|
||||
{:else if workers.length === 0}
|
||||
<p class="section-state">No Workers are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap workers-table-wrap">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal scroll regions must be keyboard-focusable.) -->
|
||||
<div class="table-wrap workers-table-wrap" role="region" aria-label="Workers" tabindex="0">
|
||||
<table class="workers-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -379,9 +381,31 @@
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<WorkerDriveGrants
|
||||
workspaceId={data.workspaceId}
|
||||
{workers}
|
||||
workersReady={catalogReady && !catalogRefreshing}
|
||||
canManage={data.workspace?.permissions.manage_runtimes === true}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.workers-page {
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workers-table-wrap {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workers-table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.worker-title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# T-724 browser fixture validation
|
||||
|
||||
These tests serve the real static production Web shell against a loopback, in-memory API fixture. They are **not actual Backend/DB/LocalFileSystem authorization or round-trip evidence**. The parent owns the separate real API checks.
|
||||
|
||||
## Runtime prerequisites
|
||||
|
||||
Deno and Playwright Chromium must be available. The server binds loopback only and owns/cleans up its shared Home fixture subprocess. Tests use an ephemeral port and independent fixture state; no real workspace data, credentials, git operations, environment test switches, or UI/CSS fixture branches are used.
|
||||
|
||||
The parent must confirm UI readiness before the production build and browser run. Tests intentionally never build automatically.
|
||||
|
||||
```sh
|
||||
# Narrow API-fixture compatibility checks (safe before UI readiness)
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-read web/workspace/test/drive-browser/fixture-api.test.ts
|
||||
|
||||
# After explicit parent UI-ready signal and successful production build only:
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/drive.browser.test.ts
|
||||
|
||||
# Responsive audit, with optional frozen production site and artifact suffix:
|
||||
deno test --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/responsive.browser.test.ts -- web/workspace/.svelte-kit/t724/verified-build/site final-reviewed
|
||||
|
||||
# Official tools/web-ux bundles; no build is performed by this runner.
|
||||
deno run --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/capture-visual.ts before <unique-run-suffix>
|
||||
deno run --config web/workspace/test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys web/workspace/test/drive-browser/capture-visual.ts after <unique-run-suffix> [workdir-relative-production-site]
|
||||
```
|
||||
|
||||
## Fixture API
|
||||
|
||||
Start `tools/web-ux/drive-fixture/server.ts <port> <build-root>` with `--allow-net --allow-read --allow-run --allow-env`. It extends the shared Home/settings API fixture through a loopback proxy and implements Drive root/list/metadata/search/read-text/read-chunk/download/mutate/upload/request-status/grants APIs. Response wire types derive from generated Drive DTOs; current client parsers are exercised by the fixture compatibility tests.
|
||||
|
||||
- Root ID `9007199254740993` tests lossless decimal references.
|
||||
- `2`: folder 資料; `3`: Markdown README; `4`: text inside folder 2.
|
||||
- `5`: valid synthetic 1200×800 `landscape.png` (16,813 compressed bytes), generated as real binary PNG data by `tools/web-ux/drive-fixture/raster.ts`; `6`: empty text; `7`: undecodable PNG; `8`: SVG download-only; `9`: text exceeding 64 KiB; `10`: long Japanese name; `11`: valid 64 KiB NUL text whose escaped JSON exceeds 256 KiB but stays below the separate 512 KiB JSON bound.
|
||||
- `home-member` and `*readonly*`: reads succeed, mutations are typed HTTP 403. No metadata permission flag is invented. The UI reacts to a denied operation, not speculative permission inference.
|
||||
- `*denied*`: typed HTTP 403 root/read; `*offline*`/`home-error`: typed HTTP 503; `*empty*`: empty successful root list; `*paged*`: 240 additional nodes.
|
||||
- `POST /__fixture/fault` JSON `{workspace, mode}`: one-shot `conflict` or committed `unknown` mutation response; persistent `image_error` binary fetch failure.
|
||||
- `home-owner`/`home-member`/`home-long`: representative authoritative Worker catalogs; REST and subscription projections come from the same generated-shape records. `home-long` includes long display names and opaque Runtime/Worker IDs. `workers-error` intentionally fails catalog reads with HTTP 503.
|
||||
- `GET /__fixture/log`: observable API request IDs/methods/paths/node IDs/CAS revisions for identity and no-resend assertions.
|
||||
- `POST /__fixture/reset`: fresh in-memory state.
|
||||
|
||||
Request delay/loss tests intercept the API boundary with Playwright and synchronize on gates, not guessed sleeps. They let the fixture commit before dropping/holding the response, then verify receipt query without blind upload retry.
|
||||
|
||||
## Visual evidence
|
||||
|
||||
Preserved production baseline: `.svelte-kit/t724/before-build/{site,output,build.log}`. Official bundles live below `.svelte-kit/t724/visual/`. Each contains screenshots, console/request/page diagnostics, accessibility snapshots, screenshot hashes, source revision/dirty context, and `review-context.json`.
|
||||
|
||||
Home/sidebar and Workers are captured under identical owner/member/error/empty/long conditions, light/dark at 1440/768/390/320. After captures add Drive owner/member/empty/error/denied/paged/Markdown/image/image-error/truncated surfaces, pinned rename/replace revision forms, and owner grant selection/read-only/read-write/revoked states. Expected injected 503/403 diagnostics and superseded breadcrumb metadata aborts remain in official context; `diagnostic-disposition.json` explicitly separates unexpected diagnostics. CLI `completed-with-errors` is not relabeled a visual pass.
|
||||
|
||||
The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes.
|
||||
|
||||
Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors.
|
||||
@@ -0,0 +1,113 @@
|
||||
/// <reference lib="dom" />
|
||||
// Captures the preserved production baseline; no UI mutations or Backend connection.
|
||||
import { chromium } from "playwright";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const artifactRoot = join(root, "web/workspace/.svelte-kit/t724/before-build");
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||
String(port),
|
||||
join(artifactRoot, "site"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
await Deno.mkdir(join(artifactRoot, "screenshots"), { recursive: true });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) throw new Error("Home fixture did not start");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
// Shared Home server predates the sidebar's authoritative /workers fetch.
|
||||
// Complete that API fixture only, without changing the production shell.
|
||||
await page.route("**/api/w/*/workers", async (route) => {
|
||||
const workspaceId = new URL(route.request().url()).pathname.split("/")[3];
|
||||
await route.fulfill({
|
||||
json: {
|
||||
workspace_id: workspaceId,
|
||||
items: [],
|
||||
limit: 100,
|
||||
source: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
for (const state of ["owner", "member", "error", "empty"]) {
|
||||
const errors: string[] = [];
|
||||
const record = (error: Error) => errors.push(String(error));
|
||||
page.on("pageerror", record);
|
||||
await page.goto(`${origin}/w/home-${state}`);
|
||||
if (state === "error") {
|
||||
await page.locator("main [role=alert]").first().waitFor();
|
||||
} else {for (
|
||||
const feed of ["home-attention", "home-active", "home-recent"]
|
||||
) await page.locator(`[data-feed-ready="${feed}"]`).waitFor();}
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const filename = `home-sidebar-workers-${state}-${colorScheme}-${width}.png`;
|
||||
await page.screenshot({
|
||||
path: join(artifactRoot, "screenshots", filename),
|
||||
fullPage: true,
|
||||
});
|
||||
evidence.push({
|
||||
filename,
|
||||
state,
|
||||
colorScheme,
|
||||
width,
|
||||
title: await page.locator("main h1").allTextContents(),
|
||||
sidebar: await page.locator("aside").allTextContents(),
|
||||
errors,
|
||||
overflow: await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
});
|
||||
page.off("pageerror", record);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
join(artifactRoot, "capture.json"),
|
||||
JSON.stringify(
|
||||
{ authority: "fixture, not actual Backend", evidence },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`Captured ${evidence.length} baseline screenshots at ${artifactRoot}/screenshots`,
|
||||
);
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Official tools/web-ux capture bundles (console, request, accessibility, context).
|
||||
// Does not build. Before always uses isolated saved site; after requires explicit parent readiness.
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import type { RouteScenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const stage = Deno.args[0];
|
||||
if (stage !== "before" && stage !== "after") {
|
||||
throw new Error("usage: capture-visual.ts before|after");
|
||||
}
|
||||
const output = join(root, "web/workspace/.svelte-kit/t724/visual");
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
const runSuffix = Deno.args[1] ? `-${Deno.args[1]}` : "";
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const runId = `t724-${stage}-${scheme}${runSuffix}`;
|
||||
const routes = ["owner", "member", "empty", "error", "long"].map((
|
||||
state,
|
||||
): RouteScenario => ({
|
||||
id: `home-${state}`,
|
||||
label: `Home/sidebar Workers ${state}`,
|
||||
path: `/w/home-${state}`,
|
||||
goal:
|
||||
"Compare unchanged Home/sidebar/Workers composition under identical API fixture conditions.",
|
||||
dataState: state === "error"
|
||||
? "Intentional 503 for Home merge-requests; visible scoped error expected."
|
||||
: `Shared Home ${state}; representative authoritative Workers (empty for new workspace).`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector:
|
||||
".workspace-home:has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])",
|
||||
},
|
||||
capturePoints: [{
|
||||
id: "initial",
|
||||
label: "Home and Workers sidebar",
|
||||
fullPage: true,
|
||||
}],
|
||||
}));
|
||||
for (const state of ["owner", "member", "empty", "error", "long"]) {
|
||||
routes.push({
|
||||
id: `workers-${state}`,
|
||||
label: `Workers ${state}`,
|
||||
path: state === "error" ? "/w/workers-error/workers" : `/w/home-${state}/workers`,
|
||||
goal: "Compare the existing Worker management surface with identical authoritative catalogs.",
|
||||
dataState:
|
||||
`Shared Home ${state} identity; representative Worker list (empty or 503 when requested). Owner controls follow workspace permission projection.`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: state === "empty"
|
||||
? ".workers-page:has-text('No Workers are visible.')"
|
||||
: state === "error"
|
||||
? ".workers-page .section-state.error"
|
||||
: ".workers-page .workers-table tbody tr",
|
||||
},
|
||||
capturePoints: [{ id: "initial", label: "Workers", fullPage: true }],
|
||||
});
|
||||
}
|
||||
if (stage === "after") {
|
||||
routes.push({
|
||||
id: "drive-form-revision",
|
||||
label: "Pinned rename and replace form revisions",
|
||||
path: "/w/home-owner/drive/3",
|
||||
goal: "Show the form's locked read revision and explicit destination/file controls.",
|
||||
dataState:
|
||||
"Revision 1 read from fixture metadata; no mutation is sent by these capture interactions.",
|
||||
ready: { kind: "selector", selector: '.drive-page[data-drive-ready="true"]' },
|
||||
capturePoints: [
|
||||
{
|
||||
id: "rename",
|
||||
label: "Rename / move pinned revision",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input:not([inputmode])", key: "Shift" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
label: "Replace file pinned revision",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{ action: "click", selector: '.drive-form button:has-text("Cancel form")' },
|
||||
{ action: "click", selector: '.drive-toolbar button:has-text("Replace file")' },
|
||||
{
|
||||
action: "wait",
|
||||
ready: { kind: "selector", selector: '.drive-form:has-text("Expected revision 1.")' },
|
||||
},
|
||||
{ action: "press", selector: ".drive-form input[type=file]", key: "Shift" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
routes.push({
|
||||
id: "worker-grants-owner",
|
||||
label: "Owner Worker Drive grant lifecycle",
|
||||
path: "/w/home-owner/workers",
|
||||
goal:
|
||||
"Use the authoritative Worker catalog to grant read-only/read-write access and revoke it without conflating Profile enablement.",
|
||||
dataState:
|
||||
"Representative REST catalog and matching protocol snapshot; in-memory grant API, not actual Backend.",
|
||||
ready: { kind: "selector", selector: ".drive-grants select" },
|
||||
capturePoints: [
|
||||
{
|
||||
id: "selected",
|
||||
label: "Selected Worker without grants",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants .worker-select select",
|
||||
key: "ArrowDown",
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants .worker-select select",
|
||||
key: "Enter",
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-only",
|
||||
label: "Read-only grant",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
{ action: "click", selector: ".drive-grants button[type=submit]" },
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants strong:has-text("Read only")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "read-write",
|
||||
label: "Read-write grant after explicit revoke",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button[aria-label^="Revoke Drive grant"]',
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants:has-text("No active Drive grants for this Worker.")',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button:has-text("Add Drive grant")',
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants form select",
|
||||
key: "ArrowDown",
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-grants form select",
|
||||
key: "Enter",
|
||||
},
|
||||
{ action: "click", selector: ".drive-grants button[type=submit]" },
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants strong:has-text("Read and write")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "revoked",
|
||||
label: "Grant revoked",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-grants button[aria-label^="Revoke Drive grant"]',
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-grants:has-text("No active Drive grants for this Worker.")',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (
|
||||
const [state, workspace, nodeId] of [
|
||||
["owner", "home-owner", ""],
|
||||
["member", "home-member", ""],
|
||||
["empty", "home-empty", ""],
|
||||
["error", "home-error", ""],
|
||||
["denied", "drive-denied", ""],
|
||||
["paged", "drive-paged", ""],
|
||||
["markdown", "home-owner", "/3"],
|
||||
["image", "home-owner", "/5"],
|
||||
["image-error", "home-owner", "/7"],
|
||||
["truncated", "home-owner", "/9"],
|
||||
]
|
||||
) {
|
||||
routes.push({
|
||||
id: `drive-${state}`,
|
||||
label: `Drive ${state}`,
|
||||
path: `/w/${workspace}/drive${nodeId}`,
|
||||
goal:
|
||||
"Review real production Drive layout, bounded safe preview and distinct failure states.",
|
||||
dataState: `In-memory Drive API ${state}, explicitly NOT actual Backend.`,
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: state === "image-error"
|
||||
? "main .drive-page [role=alert]"
|
||||
: 'main [data-drive-ready="true"]',
|
||||
},
|
||||
capturePoints: state === "member"
|
||||
? [
|
||||
{
|
||||
id: "initial",
|
||||
label: "Member before authority denial",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "denied-write",
|
||||
label: "Member after typed write denial",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
{
|
||||
action: "click",
|
||||
selector: '.drive-page button:has-text("New folder")',
|
||||
},
|
||||
{
|
||||
action: "fill",
|
||||
selector: ".drive-form input:not([type=file])",
|
||||
value: "member-denied-folder",
|
||||
},
|
||||
{
|
||||
action: "click",
|
||||
selector: ".drive-form button[type=submit]",
|
||||
},
|
||||
{
|
||||
action: "wait",
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: '.drive-page:has-text("Read only / access denied by Backend")',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "press",
|
||||
selector: ".drive-heading button",
|
||||
key: "Shift",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [{ id: "initial", label: `Drive ${state}`, fullPage: true }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const port = scheme === "light" ? 17241 : 17242;
|
||||
const scenario = {
|
||||
schemaVersion: 1,
|
||||
id: runId,
|
||||
title: `T-724 fixture-only ${stage} ${scheme}`,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
colorScheme: scheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Fixture account; Backend not connected",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [1440, 768, 390, 320].map((width) => ({
|
||||
label: String(width),
|
||||
width,
|
||||
height: 900,
|
||||
})),
|
||||
routes,
|
||||
processes: [{
|
||||
id: "drive-fixture",
|
||||
command: Deno.execPath(),
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
Deno.args[2]
|
||||
? join(root, Deno.args[2])
|
||||
: stage === "before"
|
||||
? join(root, "web/workspace/.svelte-kit/t724/before-build/site")
|
||||
: join(root, "web/workspace/build"),
|
||||
],
|
||||
readyUrl: `http://127.0.0.1:${port}/health`,
|
||||
}],
|
||||
};
|
||||
const path = join(output, `${stage}-${scheme}.json`);
|
||||
await Deno.writeTextFile(path, JSON.stringify(scenario, null, 2));
|
||||
const command = new Deno.Command(Deno.execPath(), {
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tools/web-ux/cli.ts",
|
||||
"capture",
|
||||
"--scenario",
|
||||
path,
|
||||
"--output",
|
||||
output,
|
||||
"--run-id",
|
||||
runId,
|
||||
],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const result = await command.output();
|
||||
if (!result.success && result.code !== 2) {
|
||||
throw new Error(
|
||||
`Official ${stage}/${scheme} capture failed (${result.code}); inspect review-context.`,
|
||||
);
|
||||
}
|
||||
// CLI exit 2 retains intentional HTTP error-state diagnostics rather than hiding them.
|
||||
// Parent must inspect the contexts; report unexpected errors separately.
|
||||
const context = JSON.parse(
|
||||
await Deno.readTextFile(join(output, runId, "review-context.json")),
|
||||
);
|
||||
const unexpected = context.captures.flatMap((
|
||||
capture: {
|
||||
route: { id: string };
|
||||
errors: { kind: string; message: string; url?: string }[];
|
||||
},
|
||||
) =>
|
||||
capture.errors.filter((error) => {
|
||||
// Selection/read-text republishing entry metadata supersedes the in-flight
|
||||
// breadcrumb lookup. Its abort is intentional and must not become a UI error.
|
||||
if (
|
||||
[
|
||||
"drive-markdown",
|
||||
"drive-form-revision",
|
||||
"drive-image",
|
||||
"drive-image-error",
|
||||
"drive-truncated",
|
||||
].includes(capture.route.id) && error.kind === "request" &&
|
||||
error.message === "net::ERR_ABORTED" &&
|
||||
error.url?.includes("/home-owner/drive/metadata?")
|
||||
) return false;
|
||||
if (
|
||||
["drive-member", "drive-denied"].includes(capture.route.id) &&
|
||||
error.kind === "document" &&
|
||||
error.message ===
|
||||
"visible UI error: Drive request failed: denied. No empty content or success is inferred from this failure."
|
||||
) return false;
|
||||
if (
|
||||
capture.route.id === "drive-error" && error.kind === "document" &&
|
||||
error.message ===
|
||||
"visible UI error: Drive request failed: storage_unavailable. No empty content or success is inferred from this failure."
|
||||
) return false;
|
||||
if (capture.route.id === "home-error") {
|
||||
return !(error.kind === "console" && error.message.includes("503")) &&
|
||||
!error.url?.includes("/home-error/merge-requests?");
|
||||
}
|
||||
if (capture.route.id === "workers-error") {
|
||||
return !(error.kind === "console" && error.message.includes("503")) &&
|
||||
!(error.url?.includes("/workers-error/workers") &&
|
||||
/HTTP 503|net::ERR_ABORTED/.test(error.message));
|
||||
}
|
||||
if (capture.route.id === "drive-member") {
|
||||
return !(error.kind === "console" && error.message.includes("403")) &&
|
||||
!(error.url?.includes("/home-member/drive/mutate") &&
|
||||
/HTTP 403|net::ERR_ABORTED/.test(error.message));
|
||||
}
|
||||
if (
|
||||
capture.route.id === "drive-error" ||
|
||||
capture.route.id === "drive-denied"
|
||||
) {
|
||||
return !(error.kind === "console" && /403|503/.test(error.message)) &&
|
||||
!error.url?.includes("/drive/");
|
||||
}
|
||||
return true;
|
||||
}).map((error) => ({ route: capture.route.id, ...error }))
|
||||
);
|
||||
await Deno.writeTextFile(
|
||||
join(output, runId, "diagnostic-disposition.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
expected:
|
||||
"Home reviews/Worker catalog error: injected 503. Drive denied/member/error: injected typed 403/503 and their precise visible failure messages. File metadata ERR_ABORTED is a superseded breadcrumb read, fenced by the UI epoch with no visible breadcrumb failure. All retained in review-context; no diagnostics are removed from official evidence.",
|
||||
unexpected,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (unexpected.length) {
|
||||
throw new Error(
|
||||
`${unexpected.length} unexpected visual diagnostics; inspect disposition.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"nodeModulesDir": "auto",
|
||||
"imports": {
|
||||
"#lib/": "../../src/lib/",
|
||||
"@std/assert": "jsr:@std/assert@1.0.19",
|
||||
"@std/path": "jsr:@std/path@1.1.4",
|
||||
"playwright": "npm:playwright@1.59.1"
|
||||
},
|
||||
"lock": false,
|
||||
"tasks": {
|
||||
"test:fixture": "deno test --allow-read fixture-api.test.ts",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys drive.browser.test.ts",
|
||||
"test:responsive": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys responsive.browser.test.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium, type Page, type Route } from "playwright";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { FixtureLog } from "../../../../tools/web-ux/drive-fixture/api.ts";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const ready = (page: Page) => page.locator('[data-drive-ready="true"]').waitFor();
|
||||
const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true });
|
||||
const draft = (page: Page) => page.getByRole("textbox", { name: /^Draft \(expected revision/ });
|
||||
const preview = (page: Page) => page.getByRole("article", { name: "Current saved content" });
|
||||
function gate() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>((resolve) => release = resolve);
|
||||
return {
|
||||
release,
|
||||
get promise() {
|
||||
return (async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
"Expected API gate was not observed/released within 30 seconds",
|
||||
),
|
||||
),
|
||||
30000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
async function harness(
|
||||
run: (
|
||||
origin: string,
|
||||
browser: Awaited<ReturnType<typeof chromium.launch>>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
// Intentionally no build here. Parent must authorize and build production UI first.
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
join(root, "web/workspace/build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
try {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("Drive fixture failed startup");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
await run(origin, browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
}
|
||||
async function editor(page: Page, text: string) {
|
||||
await ready(page);
|
||||
await button(page, "Edit text").click();
|
||||
await draft(page).fill(text);
|
||||
}
|
||||
async function rootFolder(page: Page) {
|
||||
await page.getByRole("navigation", { name: "Drive folders" }).getByRole(
|
||||
"link",
|
||||
{
|
||||
name: "Drive",
|
||||
exact: true,
|
||||
},
|
||||
).click();
|
||||
await page.getByRole("heading", { name: "Drive", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
}
|
||||
async function otherFile(page: Page) {
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "notes.txt", exact: true }).click();
|
||||
await ready(page);
|
||||
}
|
||||
async function fault(origin: string, workspace: string, mode: string) {
|
||||
const response = await fetch(`${origin}/__fixture/fault`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workspace, mode }),
|
||||
});
|
||||
await response.body?.cancel();
|
||||
}
|
||||
async function logs(origin: string) {
|
||||
return await (await fetch(`${origin}/__fixture/log`)).json() as FixtureLog[];
|
||||
}
|
||||
async function safeFulfill(
|
||||
route: Route,
|
||||
response: Awaited<ReturnType<Route["fetch"]>>,
|
||||
) {
|
||||
try {
|
||||
await route.fulfill({ response });
|
||||
} catch { /* selection intentionally canceled this old request */ }
|
||||
}
|
||||
|
||||
Deno.test("production Drive two tabs retain losing CAS draft and never retry with a newer revision", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const first = await context.newPage(), second = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
first.on("pageerror", (e) => errors.push(String(e)));
|
||||
second.on("pageerror", (e) => errors.push(String(e)));
|
||||
try {
|
||||
await first.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await second.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await editor(first, "First actor wins");
|
||||
await editor(second, "Second actor unsaved draft");
|
||||
await button(first, "Save").click();
|
||||
await first.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await button(second, "Save").click();
|
||||
await second.getByText("Revision conflict — your draft is retained.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
assertEquals(await button(second, "Save").isDisabled(), true);
|
||||
await button(second, "Refresh").click();
|
||||
await ready(second);
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
assert(
|
||||
(await second.getByText("Draft (expected revision 1)", { exact: false })
|
||||
.count()) > 0,
|
||||
);
|
||||
assert(
|
||||
(await preview(second).textContent())!.includes("First actor wins"),
|
||||
);
|
||||
await otherFile(second);
|
||||
await editor(second, "Independent second-file draft");
|
||||
await rootFolder(second);
|
||||
await second.getByRole("link", { name: "README.md", exact: true })
|
||||
.click();
|
||||
await ready(second);
|
||||
await button(second, "Edit text").click();
|
||||
assertEquals(
|
||||
await draft(second).inputValue(),
|
||||
"Second actor unsaved draft",
|
||||
);
|
||||
const casWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[
|
||||
"3",
|
||||
"1",
|
||||
], ["3", "1"]]);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive delayed old read cannot replace another file or workspace content", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const received = gate(), release = gate();
|
||||
let handled: Promise<void> = Promise.resolve();
|
||||
await page.route("**/drive/read-text?**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("id") !== "3") {
|
||||
return route.continue();
|
||||
}
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
received.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await received.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Only current file draft");
|
||||
release.release();
|
||||
await handled;
|
||||
assertEquals(await page.locator("main h1").textContent(), "notes.txt");
|
||||
assertEquals(await draft(page).inputValue(), "Only current file draft");
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
const mutation = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(
|
||||
mutation.map((x) => [x.workspace, x.nodeId, x.expectedRevision]),
|
||||
[[
|
||||
"home-owner",
|
||||
"4",
|
||||
"1",
|
||||
]],
|
||||
);
|
||||
// Full workspace navigation must likewise never resurrect the old workspace.
|
||||
await page.goto(`${origin}/w/home-member/drive/4`);
|
||||
await ready(page);
|
||||
assert((await preview(page).textContent())!.includes("Second document"));
|
||||
assertEquals(
|
||||
await page.locator("main .drive-url a").getAttribute("href"),
|
||||
"/w/home-member/drive/4",
|
||||
);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive delayed old save and status preserve destination drafts and save identity", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const sent = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/drive/mutate", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
sent.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await editor(page, "Old file transmitted draft");
|
||||
await button(page, "Save").click();
|
||||
await sent.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Destination draft never rebased");
|
||||
release.release();
|
||||
await handled;
|
||||
await page.unroute("**/drive/mutate");
|
||||
assertEquals(
|
||||
await draft(page).inputValue(),
|
||||
"Destination draft never rebased",
|
||||
);
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "README.md", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("button", { name: "Check request result" }).click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
assert(
|
||||
(await preview(page).textContent())!.includes(
|
||||
"Old file transmitted draft",
|
||||
),
|
||||
);
|
||||
await editor(page, "Another old request draft");
|
||||
await fault(origin, "home-owner", "unknown");
|
||||
await button(page, "Save").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
const statusSent = gate(), statusRelease = gate();
|
||||
let statusHandled = Promise.resolve();
|
||||
await page.route("**/drive/requests/*", (route) => {
|
||||
statusHandled = (async () => {
|
||||
const response = await route.fetch();
|
||||
statusSent.release();
|
||||
await statusRelease.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return statusHandled;
|
||||
});
|
||||
await button(page, "Check request result").click();
|
||||
await statusSent.promise;
|
||||
await otherFile(page);
|
||||
await editor(page, "Destination draft during old status");
|
||||
statusRelease.release();
|
||||
await statusHandled;
|
||||
assertEquals(
|
||||
await draft(page).inputValue(),
|
||||
"Destination draft during old status",
|
||||
);
|
||||
assert(
|
||||
(await preview(page).textContent())!.includes(
|
||||
"Destination draft never rebased",
|
||||
),
|
||||
);
|
||||
await button(page, "Save").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
const pinnedWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
|
||||
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [
|
||||
["3", "1"],
|
||||
["4", "1"],
|
||||
["3", "2"],
|
||||
["4", "2"],
|
||||
]);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive rename move delete recreate preserve stable identity and missing old URL", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
await button(page, "Rename / move").click();
|
||||
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
|
||||
"renamed.md",
|
||||
);
|
||||
await page.getByRole("textbox", { name: "Destination folder node ID" })
|
||||
.fill("2");
|
||||
await button(page, "Apply rename / move").click();
|
||||
await page.getByRole("heading", { name: "renamed.md", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/3");
|
||||
await page.getByRole("navigation", { name: "Drive folders" }).getByRole(
|
||||
"link",
|
||||
{
|
||||
name: "資料",
|
||||
},
|
||||
).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "renamed.md", exact: true }).click();
|
||||
await ready(page);
|
||||
await button(page, "Delete").click();
|
||||
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/3");
|
||||
await rootFolder(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await button(page, "New Markdown").click();
|
||||
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
|
||||
"renamed.md",
|
||||
);
|
||||
await page.getByRole("textbox", { name: "Markdown", exact: true }).fill(
|
||||
"Recreated identity",
|
||||
);
|
||||
await button(page, "Create").click();
|
||||
await page.getByRole("link", { name: "renamed.md", exact: true })
|
||||
.waitFor();
|
||||
const href = await page.getByRole("link", {
|
||||
name: "renamed.md",
|
||||
exact: true,
|
||||
}).getAttribute(
|
||||
"href",
|
||||
);
|
||||
assert(href !== "/w/home-owner/drive/3");
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
|
||||
.waitFor();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive upload response loss and navigation cancellation query receipts without resend", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive`);
|
||||
await ready(page);
|
||||
await page.route("**/drive/upload?**", async (route) => {
|
||||
await route.fetch();
|
||||
await route.abort("failed");
|
||||
});
|
||||
await button(page, "Upload").click();
|
||||
await page.getByLabel("File (maximum 16 MiB)").setInputFiles({
|
||||
name: "lost-response.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Published despite response loss"),
|
||||
});
|
||||
await button(page, "Upload file").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
1,
|
||||
);
|
||||
await button(page, "Check request result").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
await page.unroute("**/drive/upload?**");
|
||||
await button(page, "Refresh").click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "lost-response.txt" }).waitFor();
|
||||
const transmitted = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/drive/upload?**", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
transmitted.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
await button(page, "Upload").click();
|
||||
await page.getByLabel("File (maximum 16 MiB)").setInputFiles({
|
||||
name: "canceled-response.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Transmitted before navigation abort"),
|
||||
});
|
||||
await button(page, "Upload file").click();
|
||||
await transmitted.promise;
|
||||
await button(page, "Cancel transfer / check outcome").click();
|
||||
await button(page, "Check request result").waitFor();
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
release.release();
|
||||
await handled;
|
||||
await rootFolder(page);
|
||||
await button(page, "Check request result").waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
2,
|
||||
);
|
||||
await button(page, "Check request result").click();
|
||||
await page.getByText("Published — DB commit confirmed", { exact: false })
|
||||
.last().waitFor();
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/upload").length,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("UPLOAD FAILURE UI", await page.locator("main").innerText());
|
||||
console.log("UPLOAD FAILURE REQUESTS", await logs(origin));
|
||||
await page.screenshot({
|
||||
path: join(root, "web/workspace/.svelte-kit/t724/upload-failure.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive workspace switch fences a delayed previous workspace read", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const sent = gate(), release = gate();
|
||||
let handled = Promise.resolve();
|
||||
await page.route("**/home-owner/drive/read-text?**", (route) => {
|
||||
handled = (async () => {
|
||||
const response = await route.fetch();
|
||||
sent.release();
|
||||
await release.promise;
|
||||
await safeFulfill(route, response);
|
||||
})();
|
||||
return handled;
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await sent.promise;
|
||||
await page.getByRole("button", {
|
||||
name: "Workspace Home Review",
|
||||
exact: true,
|
||||
}).first()
|
||||
.click();
|
||||
await page.getByRole("menuitem", {
|
||||
name: "Shared Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.waitForURL("**/w/home-member");
|
||||
await page.locator("aside").getByRole("link", {
|
||||
name: "Drive",
|
||||
exact: true,
|
||||
}).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "資料", exact: true }).click();
|
||||
await ready(page);
|
||||
await page.getByRole("link", { name: "notes.txt", exact: true }).click();
|
||||
await ready(page);
|
||||
await editor(page, "Member-only current draft");
|
||||
release.release();
|
||||
await handled;
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-member/drive/4");
|
||||
assertEquals(await draft(page).inputValue(), "Member-only current draft");
|
||||
assert((await preview(page).textContent())!.includes("Second document"));
|
||||
assertEquals(
|
||||
(await logs(origin)).filter((x) => x.path === "/mutate").length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
release.release();
|
||||
await handled;
|
||||
await context.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive denial empty pages truncation unsafe content and image failure remain distinct", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
const external: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).hostname === "example.invalid") {
|
||||
external.push(request.url());
|
||||
}
|
||||
});
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-member/drive/3`);
|
||||
await editor(page, "Denied member draft");
|
||||
await button(page, "Save").click();
|
||||
await page.getByText(
|
||||
"Read only / access denied by Backend. Editing and upload are unavailable.",
|
||||
).waitFor();
|
||||
assertEquals(await draft(page).inputValue(), "Denied member draft");
|
||||
assertEquals(await button(page, "Save").count(), 0);
|
||||
assertEquals(await draft(page).getAttribute("readonly"), "");
|
||||
for (
|
||||
const [workspace, text] of [["drive-denied", "denied"], [
|
||||
"drive-offline",
|
||||
"unavailable",
|
||||
]]
|
||||
) {
|
||||
await page.goto(`${origin}/w/${workspace}/drive`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: new RegExp(text, "i") })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByText("This folder is empty.", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
await page.goto(`${origin}/w/drive-empty/drive`);
|
||||
await ready(page);
|
||||
await page.getByText("This folder is empty.", { exact: true }).waitFor();
|
||||
await page.goto(`${origin}/w/drive-paged/drive`);
|
||||
await ready(page);
|
||||
const firstCount = await page.locator(".drive-table tbody tr").count();
|
||||
await button(page, "Load more").click();
|
||||
await ready(page);
|
||||
assert(
|
||||
(await page.locator(".drive-table tbody tr").count()) > firstCount,
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/9`);
|
||||
await ready(page);
|
||||
await page.getByText("Preview truncated at 64 KiB.", { exact: false })
|
||||
.waitFor();
|
||||
assertEquals(await button(page, "Edit text").count(), 0);
|
||||
await page.goto(`${origin}/w/home-owner/drive/6`);
|
||||
await ready(page);
|
||||
await page.getByText("Empty file.", { exact: true }).waitFor();
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
assertEquals(
|
||||
await page.locator('main a[href^="javascript:"]').count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator("main img[onerror]").count(), 0);
|
||||
assertEquals(external, []);
|
||||
const local = page.getByRole("link", {
|
||||
name: "Local folder",
|
||||
exact: true,
|
||||
});
|
||||
assertEquals(await local.getAttribute("href"), "/w/home-owner/drive/2");
|
||||
await local.click();
|
||||
await page.waitForURL("**/w/home-owner/drive/2");
|
||||
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-owner/drive/2");
|
||||
await page.goto(`${origin}/w/home-owner/drive/8`);
|
||||
await ready(page);
|
||||
await page.getByText("This file type is download-only.", { exact: false })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator("main svg, main iframe, main object").count(),
|
||||
0,
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/5`);
|
||||
await ready(page);
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector("main .drive-image");
|
||||
return image instanceof HTMLImageElement && image.complete && image.naturalWidth === 1200 &&
|
||||
image.naturalHeight === 800;
|
||||
});
|
||||
assertEquals(
|
||||
await page.getByRole("img", { name: "landscape.png", exact: true }).evaluate((image) =>
|
||||
image instanceof HTMLImageElement ? [image.naturalWidth, image.naturalHeight] : []
|
||||
),
|
||||
[1200, 800],
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner/drive/7`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Image could not be decoded",
|
||||
}).waitFor();
|
||||
await fault(origin, "home-owner", "image_error");
|
||||
await page.goto(`${origin}/w/home-owner/drive/5`);
|
||||
await ready(page);
|
||||
await page.getByRole("alert").filter({ hasText: /unavailable/i })
|
||||
.waitFor();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Workers owner grants use the authoritative catalog while members have no grant controls", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/workers`);
|
||||
await page.getByRole("combobox", { name: "Worker", exact: true })
|
||||
.selectOption({
|
||||
label: "Documentation Worker · fixture-runtime / fixture-worker-a",
|
||||
});
|
||||
await button(page, "Add Drive grant").waitFor();
|
||||
await button(page, "Add Drive grant").click();
|
||||
await button(page, "Create Drive grant").click();
|
||||
const grants = page.getByRole("region", { name: "Worker Drive grants" });
|
||||
await grants.getByText("Read only", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: /^Revoke Drive grant/ }).click();
|
||||
await grants.getByText("No active Drive grants for this Worker.", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await button(page, "Add Drive grant").click();
|
||||
await page.getByRole("combobox", { name: "Access", exact: true })
|
||||
.selectOption("read_write");
|
||||
await button(page, "Create Drive grant").click();
|
||||
await grants.getByText("Read and write", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: /^Revoke Drive grant/ }).click();
|
||||
await grants.getByText("No active Drive grants for this Worker.", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
const mutations = (await logs(origin)).filter((event) =>
|
||||
event.path.startsWith("/grants") && event.method !== "GET"
|
||||
);
|
||||
assertEquals(
|
||||
mutations.map((event) => [event.workspace, event.method, event.path]),
|
||||
[
|
||||
["home-owner", "POST", "/grants"],
|
||||
["home-owner", "DELETE", "/grants/1"],
|
||||
["home-owner", "POST", "/grants"],
|
||||
["home-owner", "DELETE", "/grants/2"],
|
||||
],
|
||||
);
|
||||
await page.goto(`${origin}/w/home-member/workers`);
|
||||
await page.locator(".workers-table tbody tr").first().waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("region", { name: "Worker Drive grants" }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await button(page, "Add Drive grant").count(), 0);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
|
||||
Deno.test("production Drive Markdown safe image placeholders preserve each image alt label", () =>
|
||||
harness(async (origin, browser) => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(`${origin}/w/home-owner/drive/3`);
|
||||
await ready(page);
|
||||
const labels = await page.locator(".drive-inline-image")
|
||||
.allTextContents();
|
||||
assert(
|
||||
labels.some((label) => label.includes("Image: External")),
|
||||
`External image alt label lost: ${JSON.stringify(labels)}`,
|
||||
);
|
||||
assert(
|
||||
labels.some((label) => label.includes("Image: Image")),
|
||||
`Local image alt label lost: ${JSON.stringify(labels)}`,
|
||||
);
|
||||
assertEquals(await preview(page).locator("img").count(), 0);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,140 @@
|
||||
import { assert, assertEquals, assertRejects } from "@std/assert";
|
||||
import { createDriveFixture, fixtureRootId } from "../../../../tools/web-ux/drive-fixture/api.ts";
|
||||
import { createDriveClient, DriveRequestError } from "../../src/lib/workspace/drive/api.ts";
|
||||
function setup() {
|
||||
const fixture = createDriveFixture();
|
||||
const fetchFixture: typeof fetch = async (input, init) => {
|
||||
const request = new Request(new URL(String(input), "http://127.0.0.1"), init);
|
||||
const response = await fixture.handler(request);
|
||||
if (!response) throw new Error(`Unimplemented fixture ${request.url}`);
|
||||
return response;
|
||||
};
|
||||
return { fixture, client: createDriveClient(fetchFixture), fetchFixture };
|
||||
}
|
||||
const ref = (workspace_id: string, node_id: string) => ({ workspace_id, node_id });
|
||||
Deno.test("browser fixture preserves decimal identity, bounded reads, binary and page cursors", async () => {
|
||||
const { client, fetchFixture } = setup();
|
||||
const root = await client.root("drive-paged");
|
||||
assertEquals(root.entry.node_id, fixtureRootId);
|
||||
const first = await client.list("drive-paged", root.entry, { limit: 100 });
|
||||
const second = await client.list("drive-paged", root.entry, {
|
||||
limit: 100,
|
||||
after: first.next_after,
|
||||
});
|
||||
assertEquals(first.entries.length, 100);
|
||||
assertEquals(second.entries.length, 100);
|
||||
assert(
|
||||
!second.entries.some((entry) =>
|
||||
first.entries.some((other) => other.entry.node_id === entry.entry.node_id)
|
||||
),
|
||||
);
|
||||
const text = await client.readText("drive-paged", ref("drive-paged", "9"));
|
||||
assertEquals(text.truncated, true);
|
||||
assert(new TextEncoder().encode(text.text).length <= 65536);
|
||||
const image = await client.metadata("drive-paged", ref("drive-paged", "5"));
|
||||
const imageBytes = await client.image("drive-paged", image);
|
||||
assertEquals(imageBytes.size, image.size);
|
||||
assert(imageBytes.size < 256 * 1024);
|
||||
const png = new Uint8Array(await imageBytes.arrayBuffer());
|
||||
const dimensions = new DataView(png.buffer);
|
||||
assertEquals([dimensions.getUint32(16), dimensions.getUint32(20)], [1200, 800]);
|
||||
const escapedWire = await fetchFixture(
|
||||
"/api/w/drive-paged/drive/read-text?entry_workspace_id=drive-paged&id=11&max_bytes=65536",
|
||||
);
|
||||
const escapedLength = (await escapedWire.arrayBuffer()).byteLength;
|
||||
assert(escapedLength > 256 * 1024 && escapedLength < 512 * 1024);
|
||||
const escaped = await client.readText("drive-paged", ref("drive-paged", "11"));
|
||||
assertEquals(escaped.text, "\u0000".repeat(65536));
|
||||
assertEquals(escaped.truncated, false);
|
||||
});
|
||||
Deno.test("browser fixture two revisions conflict without committing the losing request", async () => {
|
||||
const { client } = setup();
|
||||
const entry = await client.metadata("home-owner", ref("home-owner", "3"));
|
||||
await client.mutate("home-owner", "tab-one", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text: "first actor",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
const failure = await assertRejects(
|
||||
() =>
|
||||
client.mutate("home-owner", "tab-two", {
|
||||
operation: "update_text",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text: "second actor draft",
|
||||
content_type: "text/markdown",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((failure as DriveRequestError).code, "conflict");
|
||||
assertEquals((await client.status("home-owner", "tab-one")).state, "committed");
|
||||
assertEquals((await client.status("home-owner", "tab-two")).state, "uncommitted");
|
||||
assertEquals((await client.readText("home-owner", entry.entry)).text, "first actor");
|
||||
});
|
||||
Deno.test("browser fixture upload unknown is reconciled by receipt and denied writes never commit", async () => {
|
||||
const { client, fetchFixture } = setup();
|
||||
await fetchFixture("/__fixture/fault", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ workspace: "home-owner", mode: "unknown" }),
|
||||
});
|
||||
const root = await client.root("home-owner");
|
||||
const failure = await assertRejects(
|
||||
() =>
|
||||
client.upload("home-owner", "upload-one", new Blob(["upload bytes"]), {
|
||||
operation: "create",
|
||||
parent: root.entry,
|
||||
name: "upload.txt",
|
||||
content_type: "text/plain",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((failure as DriveRequestError).code, "outcome_unknown");
|
||||
assertEquals((await client.status("home-owner", "upload-one")).state, "committed");
|
||||
const memberRoot = await client.root("home-member");
|
||||
const denied = await assertRejects(
|
||||
() =>
|
||||
client.mutate("home-member", "member-one", {
|
||||
operation: "create_folder",
|
||||
parent: memberRoot.entry,
|
||||
name: "denied",
|
||||
}),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((denied as DriveRequestError).code, "denied");
|
||||
assertEquals((await client.status("home-member", "member-one")).state, "uncommitted");
|
||||
});
|
||||
Deno.test("browser fixture rename and move retain identity while delete and recreate do not reuse links", async () => {
|
||||
const { client } = setup();
|
||||
let entry = await client.metadata("home-owner", ref("home-owner", "3"));
|
||||
const latest = entry.latest_url;
|
||||
const moved = await client.mutate("home-owner", "move", {
|
||||
operation: "relocate",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
parent: ref("home-owner", "2"),
|
||||
name: "renamed.md",
|
||||
});
|
||||
entry = moved.entry!;
|
||||
assertEquals(entry.latest_url, latest);
|
||||
await client.mutate("home-owner", "delete", {
|
||||
operation: "delete",
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
});
|
||||
const replacement = await client.mutate("home-owner", "recreate", {
|
||||
operation: "create_text",
|
||||
parent: ref("home-owner", "2"),
|
||||
name: "renamed.md",
|
||||
text: "new entity",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
assert(replacement.entry!.entry.node_id !== entry.entry.node_id);
|
||||
const missing = await assertRejects(
|
||||
() => client.metadata("home-owner", entry.entry),
|
||||
DriveRequestError,
|
||||
);
|
||||
assertEquals((missing as DriveRequestError).code, "not_found");
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/// <reference lib="dom" />
|
||||
// Responsive DOM geometry audit over the frozen production shell and fake API.
|
||||
// Read-only observation: no UI/CSS mutation or actual Backend connection.
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
|
||||
const auditId = Deno.args[1] ?? "verified";
|
||||
Deno.test("production Drive and Workers constrain page overflow and expose keyboard grant controls at every viewport", async () => {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"--allow-run",
|
||||
"--allow-env",
|
||||
join(root, "tools/web-ux/drive-fixture/server.ts"),
|
||||
String(port),
|
||||
join(root, Deno.args[0] ?? "web/workspace/build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "inherit",
|
||||
}).spawn();
|
||||
const evidence: unknown[] = [];
|
||||
try {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) break;
|
||||
} catch { /* startup */ }
|
||||
if (Date.now() > deadline) throw new Error("fixture failed startup");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (e) => pageErrors.push(String(e)));
|
||||
for (
|
||||
const [workspace, surface] of [
|
||||
["home-owner", "workers"],
|
||||
["home-member", "workers"],
|
||||
["home-empty", "workers"],
|
||||
["workers-error", "workers"],
|
||||
["home-long", "workers"],
|
||||
["home-owner", "drive"],
|
||||
["home-member", "drive"],
|
||||
["home-empty", "drive"],
|
||||
["home-error", "drive"],
|
||||
["drive-paged", "drive"],
|
||||
["home-owner", "drive/10"],
|
||||
["home-owner", "drive/5"],
|
||||
]
|
||||
) {
|
||||
await page.goto(`${origin}/w/${workspace}/${surface}`);
|
||||
if (surface.startsWith("drive")) {
|
||||
await page.locator('[data-drive-ready="true"]').waitFor();
|
||||
} else if (workspace === "workers-error") {
|
||||
await page.locator(".workers-page .section-state.error")
|
||||
.waitFor();
|
||||
} else if (workspace === "home-empty") {
|
||||
await page.getByText("No Workers are visible.", { exact: true })
|
||||
.waitFor();
|
||||
} else {await page.locator(".workers-table tbody tr").first()
|
||||
.waitFor();}
|
||||
if (surface === "drive/5") {
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector("main .drive-image");
|
||||
return image instanceof HTMLImageElement && image.complete &&
|
||||
image.naturalWidth === 1200 && image.naturalHeight === 800;
|
||||
});
|
||||
}
|
||||
if (workspace === "home-owner" && surface === "workers") {
|
||||
await page.getByRole("combobox", {
|
||||
name: "Worker",
|
||||
exact: true,
|
||||
}).selectOption({
|
||||
label: "Documentation Worker · fixture-runtime / fixture-worker-a",
|
||||
});
|
||||
await page.getByRole("button", {
|
||||
name: "Add Drive grant",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Access",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Refresh grants",
|
||||
exact: true,
|
||||
}).press("Tab");
|
||||
const focus = await page.evaluate(() => ({
|
||||
tag: document.activeElement?.tagName,
|
||||
outline: document.activeElement
|
||||
? getComputedStyle(document.activeElement).outlineStyle
|
||||
: "none",
|
||||
}));
|
||||
assert(
|
||||
focus.tag === "BUTTON" && focus.outline !== "none",
|
||||
JSON.stringify(focus),
|
||||
);
|
||||
}
|
||||
if (workspace === "workers-error" && width === 320 && colorScheme === "dark") {
|
||||
await page.screenshot({
|
||||
path: join(root, `web/workspace/.svelte-kit/t724/workers-error-${auditId}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
const geometry = await page.evaluate(() => {
|
||||
const main = document.querySelector("main")! as HTMLElement;
|
||||
const grants = main.querySelector(".drive-grants");
|
||||
const boundedTables = [
|
||||
...main.querySelectorAll(".table-wrap, .drive-table-region"),
|
||||
].map((el) => ({
|
||||
width: el.clientWidth,
|
||||
scrollWidth: el.scrollWidth,
|
||||
overflowX: getComputedStyle(el).overflowX,
|
||||
}));
|
||||
const raster = main.querySelector(".drive-image");
|
||||
const image = raster instanceof HTMLImageElement
|
||||
? {
|
||||
naturalWidth: raster.naturalWidth,
|
||||
naturalHeight: raster.naturalHeight,
|
||||
renderedWidth: raster.getBoundingClientRect().width,
|
||||
renderedHeight: raster.getBoundingClientRect().height,
|
||||
availableWidth: raster.parentElement!.getBoundingClientRect().width,
|
||||
heightLimit: innerHeight * 0.6,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
image,
|
||||
pageWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: innerWidth,
|
||||
mainWidth: main.clientWidth,
|
||||
mainScrollWidth: main.scrollWidth,
|
||||
documentScrollY: scrollY,
|
||||
mainScrollTop: main.scrollTop,
|
||||
mainTop: main.getBoundingClientRect().top,
|
||||
firstHeadingTop: main.querySelector("h1")?.getBoundingClientRect().top ?? null,
|
||||
grantsWidth: grants?.getBoundingClientRect().width ?? null,
|
||||
boundedTables,
|
||||
};
|
||||
});
|
||||
evidence.push({
|
||||
colorScheme,
|
||||
width,
|
||||
workspace,
|
||||
surface,
|
||||
...geometry,
|
||||
});
|
||||
assert(
|
||||
geometry.pageWidth <= width,
|
||||
JSON.stringify(evidence.at(-1)),
|
||||
);
|
||||
assert(
|
||||
geometry.mainScrollWidth <= geometry.mainWidth + 1,
|
||||
JSON.stringify(evidence.at(-1)),
|
||||
);
|
||||
if (surface === "drive/5") {
|
||||
const image = geometry.image;
|
||||
assert(image !== null);
|
||||
assertEquals([image.naturalWidth, image.naturalHeight], [1200, 800]);
|
||||
assert(image.renderedWidth > 100 && image.renderedHeight > 100);
|
||||
assert(image.renderedWidth <= image.availableWidth + 1);
|
||||
assert(image.renderedHeight <= image.heightLimit + 1);
|
||||
assert(Math.abs(image.renderedWidth / image.renderedHeight - 1.5) < 0.02);
|
||||
}
|
||||
if (workspace === "home-member" && surface === "workers") {
|
||||
assertEquals(
|
||||
await page.getByRole("region", {
|
||||
name: "Worker Drive grants",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertEquals(pageErrors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
await Deno.writeTextFile(
|
||||
join(root, `web/workspace/.svelte-kit/t724/responsive-${auditId}.json`),
|
||||
JSON.stringify(
|
||||
{ authority: "fixture, not actual Backend", evidence },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
# Real Drive API/Web roundtrip (not the browser fixture)
|
||||
|
||||
This harness uses a fresh RAII temporary directory, real Server SQLite records, FeatureStorage Drive nodes and `object_store::local::LocalFileSystem` blobs. It creates a Workspace through T-717's actual repositoryless `/api/workspaces` endpoint. No repositories, user's data, live Server, environment-variable test configuration, Tools or WIP are involved.
|
||||
|
||||
The ordinary Rust test exercises loopback HTTP signed Worker ingress: explicit read-only/read-write grants, rejection of read-only writes, Worker-created bytes visible through Web/user API, and immediate grant revocation (even when the request also carries an owner's bearer token). Only the Runtime worker-spawn boundary uses the existing deterministic workdirless fixture runtime.
|
||||
|
||||
The explicit process test starts a loopback Axum Server and launches Deno against it. `roundtrip.ts` imports the production `src/lib/workspace/drive/api.ts` adapter, not a copy or mock. Its typed `fetchFn` resolves relative paths only to the bound loopback origin and adds fixture bearer authorization. Tokens travel over stdin, not command-line arguments or logs; Rust failure diagnostics defensively redact both fixture bearer tokens. Deno has network permission only for the exact loopback port, uses no npm dependencies, and runs with `--cached-only`. Each listener is abort-on-drop, child processes are kill-on-drop, and the child has a 60-second upper timeout, without sleeps or readiness polling.
|
||||
|
||||
Coverage: Worker file list/read, Japanese folder/Markdown names, create/read/edit/download, empty files, actual PNG upload/authenticated image/download and binary update, publication receipt lookup, paged list, two authenticated accounts racing a common revision (one commit/one typed conflict, no hidden retry), stable latest URL through rename and move, deleted URL and same-name recreation, and unauthorized latest URLs. The exact Workers-page `drive-grants/api.ts` adapter also lists persisted grants, creates both access levels, revokes them and re-reads durable state, and rejects member administration. Its global browser fetch is temporarily origin/auth-bound in this isolated child; network responses are never mocked. This tests real authenticated HTTP with test bearer credentials, not a Passkey/cookie browser login or a full Runtime process. UI dirty-draft retention, delayed response handling, visual review and browser cancellation belong to the **separate browser fixture**, not this API test.
|
||||
|
||||
## Registration
|
||||
|
||||
The inline `mod tests` block in `crates/workspace-server/src/server.rs` registers the module alongside the existing Drive API tests:
|
||||
|
||||
```rust
|
||||
mod drive_web_roundtrip_tests;
|
||||
```
|
||||
|
||||
No product routing modification is needed. The scoped production router is cached by the harness without starting unrelated Orchestrator hook tasks; production server ingress, authorization and API/storage handlers still run.
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests
|
||||
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored
|
||||
```
|
||||
|
||||
The second command requires Deno on PATH. Its ignored marker is an explicit process/E2E prerequisite, not an environment-variable gate or silent fallback. An absent Deno fails the explicit test. The ordinary real HTTP test does not require Deno.
|
||||
|
||||
The script is harness-only: it receives fresh credentials and Workspace identity from the parent over stdin; do not invoke it against a live Server. Type-check it without starting the Server:
|
||||
|
||||
```sh
|
||||
deno check --config web/workspace/test/drive-real-api/deno.json \
|
||||
web/workspace/test/drive-real-api/roundtrip.ts
|
||||
```
|
||||
|
||||
## Validation evidence
|
||||
|
||||
Validated against the production Server endpoints and Web adapter, not the browser mock routes:
|
||||
|
||||
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --test-threads=4`: **1 passed, 1 explicitly ignored** (the process prerequisite).
|
||||
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored --test-threads=4`: **1 passed**; the actual Deno adapter process completed against real SQLite/LocalFileSystem HTTP.
|
||||
- `cargo test -p yoi-workspace-server -- --test-threads=4`: **693 library tests passed, 1 ignored; 11 binary tests passed; doc-tests passed**.
|
||||
|
||||
The repositoryless fixture was adjusted to retain the production runtime trust gate's exact normalized binding snapshot and to scope the reusable Runtime observation to the newly bootstrapped Workspace. Neither fix changes the API, Drive authorization policy, Tools, WIP, or browser fixtures.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"nodeModulesDir": "none",
|
||||
"lock": false,
|
||||
"imports": {
|
||||
"#lib/": "../../src/lib/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Invoked only by the Rust loopback harness. No mocked responses, live server,
|
||||
// browser fixtures, environment variables, or bearer credentials in argv/logs.
|
||||
import {
|
||||
createDriveClient,
|
||||
type DriveClient,
|
||||
driveDownloadUrl,
|
||||
type DriveEntry,
|
||||
type DriveMutation,
|
||||
DriveRequestError,
|
||||
readBoundedDriveBytes,
|
||||
} from "../../src/lib/workspace/drive/api.ts";
|
||||
import {
|
||||
createDriveGrant,
|
||||
DriveGrantError,
|
||||
listDriveGrants,
|
||||
revokeDriveGrant,
|
||||
} from "../../src/lib/workspace/drive-grants/api.ts";
|
||||
// Preserve the real network implementation before temporarily binding the grant
|
||||
// adapter's same-origin global fetch to this isolated child/loopback authority.
|
||||
const networkFetch = globalThis.fetch;
|
||||
|
||||
interface Fixture {
|
||||
origin: string;
|
||||
workspace_id: string;
|
||||
owner_token: string;
|
||||
second_token: string;
|
||||
worker_entry: DriveEntry;
|
||||
}
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function equal(actual: unknown, expected: unknown, message: string): void {
|
||||
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
|
||||
}
|
||||
async function rejected(
|
||||
call: () => Promise<unknown>,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call();
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveRequestError,
|
||||
"rejection must preserve typed Drive API error",
|
||||
);
|
||||
equal(error.code, code, `expected typed ${code} rejection`);
|
||||
equal(
|
||||
error.classification,
|
||||
"not_committed",
|
||||
"rejected request must not look successful/unknown",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected ${code} rejection`);
|
||||
}
|
||||
function authenticatedFetch(origin: string, token: string): typeof fetch {
|
||||
const endpoint = new URL(origin);
|
||||
assert(
|
||||
endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1",
|
||||
"fixture must be loopback only",
|
||||
);
|
||||
return (input, init) => {
|
||||
const path = input instanceof Request ? input.url : String(input);
|
||||
const url = new URL(path, endpoint);
|
||||
assert(
|
||||
url.origin === endpoint.origin && url.pathname.startsWith("/api/"),
|
||||
"never send fixture credentials off-origin",
|
||||
);
|
||||
const headers = new Headers(
|
||||
input instanceof Request ? input.headers : undefined,
|
||||
);
|
||||
new Headers(init && "headers" in init ? init.headers : undefined).forEach((
|
||||
value,
|
||||
key,
|
||||
) => headers.set(key, value));
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
return networkFetch(url, { ...init, headers, redirect: "error" });
|
||||
};
|
||||
}
|
||||
async function saved(
|
||||
client: DriveClient,
|
||||
ws: string,
|
||||
request: string,
|
||||
mutation: DriveMutation,
|
||||
): Promise<DriveEntry> {
|
||||
const result = await client.mutate(ws, request, mutation);
|
||||
assert(result.entry !== null, "creation/update must return live entry");
|
||||
return result.entry;
|
||||
}
|
||||
async function download(
|
||||
fetchFn: typeof fetch,
|
||||
ws: string,
|
||||
entry: DriveEntry,
|
||||
): Promise<Uint8Array> {
|
||||
const response = await fetchFn(
|
||||
driveDownloadUrl(ws, entry.entry, entry.revision),
|
||||
);
|
||||
assert(response.status === 200, "download must succeed at exact revision");
|
||||
equal(
|
||||
response.headers.get("x-content-type-options"),
|
||||
"nosniff",
|
||||
"downloads must not sniff active content",
|
||||
);
|
||||
assert(
|
||||
response.headers.get("content-disposition")?.startsWith("attachment;"),
|
||||
"download must be attachment",
|
||||
);
|
||||
return readBoundedDriveBytes(response, 16 * 1024 * 1024);
|
||||
}
|
||||
|
||||
// Credentials arrive exclusively over the child pipe from a fresh temporary DB.
|
||||
const fixture: Fixture = JSON.parse(
|
||||
await new Response(Deno.stdin.readable).text(),
|
||||
);
|
||||
const ws = fixture.workspace_id;
|
||||
const fetchOwner = authenticatedFetch(fixture.origin, fixture.owner_token);
|
||||
const owner = createDriveClient(fetchOwner);
|
||||
const second = createDriveClient(
|
||||
authenticatedFetch(fixture.origin, fixture.second_token),
|
||||
);
|
||||
const root = await owner.root(ws);
|
||||
const initial = await owner.list(ws, root.entry);
|
||||
equal(initial.entries.map((entry) => entry.entry.node_id), [
|
||||
fixture.worker_entry.entry.node_id,
|
||||
], "Worker-created file must appear in the real Web adapter list");
|
||||
equal(
|
||||
(await owner.readText(ws, fixture.worker_entry.entry)).text,
|
||||
"# Worker artifact\n",
|
||||
"Web adapter must read Worker bytes from LocalFileSystem",
|
||||
);
|
||||
// Exercise the exact grant adapter used by the Workers page, not a hand-written
|
||||
// HTTP facsimile. Only URL resolution/auth are bound; every response is real DB/API.
|
||||
try {
|
||||
globalThis.fetch = fetchOwner;
|
||||
const grants = await listDriveGrants(ws);
|
||||
assert(
|
||||
grants.length === 2,
|
||||
"real Worker grants must be listed by the Web adapter",
|
||||
);
|
||||
const worker = grants[0];
|
||||
for (const access of ["read_only", "read_write"] as const) {
|
||||
const created = await createDriveGrant(ws, {
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
access,
|
||||
});
|
||||
equal(
|
||||
created.access,
|
||||
access,
|
||||
"grant access must not be silently downgraded",
|
||||
);
|
||||
assert(
|
||||
(await listDriveGrants(ws)).some((grant) =>
|
||||
grant.grant_id === created.grant_id && !grant.revoked
|
||||
),
|
||||
"grant must be durably visible",
|
||||
);
|
||||
const revoked = await revokeDriveGrant(ws, created);
|
||||
assert(
|
||||
revoked.revoked &&
|
||||
(await listDriveGrants(ws)).some((grant) =>
|
||||
grant.grant_id === created.grant_id && grant.revoked
|
||||
),
|
||||
"revoke must be durably visible",
|
||||
);
|
||||
}
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
|
||||
try {
|
||||
await listDriveGrants(ws);
|
||||
throw new Error("member must not manage grants");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveGrantError && error.outcome === "not_committed",
|
||||
"member grant read must be a real authorized rejection",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = networkFetch;
|
||||
}
|
||||
|
||||
const folder = await saved(owner, ws, "web-folder", {
|
||||
operation: "create_folder",
|
||||
parent: root.entry,
|
||||
name: "共有資料",
|
||||
});
|
||||
equal(
|
||||
(await owner.list(ws, folder.entry)).entries,
|
||||
[],
|
||||
"new folder must be empty",
|
||||
);
|
||||
const markdown = await saved(owner, ws, "web-markdown", {
|
||||
operation: "create_text",
|
||||
parent: folder.entry,
|
||||
name: "日本語.md",
|
||||
text: "# 初版\n",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
equal(
|
||||
(await owner.readText(ws, markdown.entry)).text,
|
||||
"# 初版\n",
|
||||
"real Markdown read must match created bytes",
|
||||
);
|
||||
const updated = await saved(owner, ws, "web-edit", {
|
||||
operation: "update_text",
|
||||
id: markdown.entry,
|
||||
expected_revision: markdown.revision,
|
||||
text: "# 更新\n",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
equal(
|
||||
new TextDecoder().decode(await download(fetchOwner, ws, updated)),
|
||||
"# 更新\n",
|
||||
"updated Markdown download must use real blob bytes",
|
||||
);
|
||||
const empty = await saved(owner, ws, "web-empty", {
|
||||
operation: "create_text",
|
||||
parent: folder.entry,
|
||||
name: "empty.txt",
|
||||
text: "",
|
||||
content_type: "text/plain",
|
||||
});
|
||||
equal(
|
||||
(await owner.readText(ws, empty.entry)).text,
|
||||
"",
|
||||
"empty file is not a missing/error response",
|
||||
);
|
||||
equal(
|
||||
(await download(fetchOwner, ws, empty)).length,
|
||||
0,
|
||||
"empty file download must succeed with zero bytes",
|
||||
);
|
||||
// A real 1x1 PNG, not a claimed media type over arbitrary text.
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=",
|
||||
),
|
||||
(character) => character.charCodeAt(0),
|
||||
);
|
||||
const uploaded = await owner.upload(
|
||||
ws,
|
||||
"web-png",
|
||||
new Blob([png], { type: "image/png" }),
|
||||
{
|
||||
operation: "create",
|
||||
parent: folder.entry,
|
||||
name: "画像.png",
|
||||
content_type: "image/png",
|
||||
},
|
||||
);
|
||||
assert(uploaded.entry !== null, "PNG upload must publish metadata");
|
||||
equal(
|
||||
uploaded.entry.size,
|
||||
png.length,
|
||||
"binary byte count must survive actual upload",
|
||||
);
|
||||
equal(
|
||||
[
|
||||
...new Uint8Array(
|
||||
await (await owner.image(ws, uploaded.entry)).arrayBuffer(),
|
||||
),
|
||||
],
|
||||
[...png],
|
||||
"authenticated image bytes must survive upload, storage and Web adapter download",
|
||||
);
|
||||
equal(
|
||||
[...(await download(fetchOwner, ws, uploaded.entry))],
|
||||
[...png],
|
||||
"PNG download must match original bytes",
|
||||
);
|
||||
const binaryUpdated = await owner.upload(
|
||||
ws,
|
||||
"web-empty-to-png",
|
||||
new Blob([png]),
|
||||
{
|
||||
operation: "update",
|
||||
id: empty.entry,
|
||||
expected_revision: empty.revision,
|
||||
content_type: "image/png",
|
||||
},
|
||||
);
|
||||
assert(binaryUpdated.entry !== null, "binary update must publish metadata");
|
||||
equal(
|
||||
[...await download(fetchOwner, ws, binaryUpdated.entry)],
|
||||
[...png],
|
||||
"binary update must replace zero-length file bytes",
|
||||
);
|
||||
const status = await owner.status(ws, "web-png");
|
||||
equal(
|
||||
status.state,
|
||||
"committed",
|
||||
"adapter must distinguish publication from transmission",
|
||||
);
|
||||
equal(
|
||||
status.response,
|
||||
uploaded,
|
||||
"status must refer to exact real upload receipt",
|
||||
);
|
||||
const firstPage = await owner.list(ws, folder.entry, { limit: 1 });
|
||||
assert(
|
||||
firstPage.next_after !== null && firstPage.entries.length === 1,
|
||||
"real list must expose a service-issued page cursor",
|
||||
);
|
||||
const following = await owner.list(ws, folder.entry, {
|
||||
after: firstPage.next_after,
|
||||
});
|
||||
equal(
|
||||
new Set(
|
||||
[...firstPage.entries, ...following.entries].map((entry) =>
|
||||
entry.entry.node_id
|
||||
),
|
||||
).size,
|
||||
3,
|
||||
"paged traversal must contain each file once",
|
||||
);
|
||||
|
||||
// Two authenticated accounts read the same revision, then race real HTTP writes.
|
||||
const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.revision,
|
||||
updated.revision,
|
||||
"second actor must observe same revision",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
[owner, second].map((client, index) =>
|
||||
saved(client, ws, `web-race-${index}`, {
|
||||
operation: "update_text",
|
||||
id: updated.entry,
|
||||
expected_revision: updated.revision,
|
||||
text: drafts[index],
|
||||
content_type: "text/markdown",
|
||||
})
|
||||
),
|
||||
);
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-revision two-actor CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
loser?.status === "rejected" && loser.reason instanceof DriveRequestError,
|
||||
"loser must expose typed conflict, not silently retry",
|
||||
);
|
||||
equal(loser.reason.code, "conflict", "CAS loser must remain a conflict");
|
||||
const winnerIndex = race.findIndex((result) => result.status === "fulfilled");
|
||||
const latest = await owner.readText(ws, updated.entry);
|
||||
equal(
|
||||
latest.text,
|
||||
drafts[winnerIndex],
|
||||
"durable content must be the winning actor's draft",
|
||||
);
|
||||
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
|
||||
// retention is deliberately verified by the separate browser fixture.
|
||||
|
||||
// Latest references bind node IDs, not names/parents, and never become public.
|
||||
const stableUrl = markdown.latest_url;
|
||||
const renamed = await saved(owner, ws, "web-rename", {
|
||||
operation: "relocate",
|
||||
id: latest.entry.entry,
|
||||
expected_revision: latest.entry.revision,
|
||||
parent: folder.entry,
|
||||
name: "改名.md",
|
||||
});
|
||||
const moved = await saved(owner, ws, "web-move", {
|
||||
operation: "relocate",
|
||||
id: renamed.entry,
|
||||
expected_revision: renamed.revision,
|
||||
parent: root.entry,
|
||||
name: renamed.name,
|
||||
});
|
||||
equal(moved.entry, markdown.entry, "rename/move must preserve node identity");
|
||||
equal(moved.latest_url, stableUrl, "rename/move must preserve latest URL");
|
||||
equal(
|
||||
(await owner.metadata(ws, markdown.entry)).parent,
|
||||
root.entry,
|
||||
"original ref must resolve new parent",
|
||||
);
|
||||
equal(
|
||||
await (await fetchOwner(stableUrl)).text(),
|
||||
drafts[winnerIndex],
|
||||
"original latest URL must resolve current bytes",
|
||||
);
|
||||
const unauthenticated = await fetch(new URL(stableUrl, fixture.origin), {
|
||||
redirect: "error",
|
||||
});
|
||||
assert(
|
||||
unauthenticated.status === 403 || unauthenticated.status === 401,
|
||||
"possessing a latest URL must not authorize reads",
|
||||
);
|
||||
await unauthenticated.body?.cancel();
|
||||
await rejected(
|
||||
() =>
|
||||
owner.metadata(ws, { workspace_id: ws, node_id: "9223372036854775807" }),
|
||||
"not_found",
|
||||
);
|
||||
const deleted = await owner.mutate(ws, "web-delete", {
|
||||
operation: "delete",
|
||||
id: moved.entry,
|
||||
expected_revision: moved.revision,
|
||||
});
|
||||
equal(deleted.entry, null, "delete must remove live metadata");
|
||||
await rejected(() => owner.readText(ws, moved.entry), "not_found");
|
||||
const oldUrl = await fetchOwner(stableUrl);
|
||||
equal(
|
||||
oldUrl.status,
|
||||
404,
|
||||
"deleted stable URL must not resolve a recreated same-name file",
|
||||
);
|
||||
await oldUrl.body?.cancel();
|
||||
const recreated = await saved(owner, ws, "web-recreate", {
|
||||
operation: "create_text",
|
||||
parent: root.entry,
|
||||
name: moved.name,
|
||||
text: "recreated",
|
||||
content_type: "text/markdown",
|
||||
});
|
||||
assert(
|
||||
recreated.entry.node_id !== moved.entry.node_id &&
|
||||
recreated.latest_url !== stableUrl,
|
||||
"same-name recreate must allocate a new ID and latest URL",
|
||||
);
|
||||
|
||||
// The decoded text bound is independent of JSON escape expansion on the wire.
|
||||
const escapedText = "\u0000".repeat(64 * 1024);
|
||||
const escaped = await saved(owner, ws, "web-escaped-text", {
|
||||
operation: "create_text",
|
||||
parent: root.entry,
|
||||
name: "escaped.txt",
|
||||
text: escapedText,
|
||||
content_type: "text/plain",
|
||||
});
|
||||
assert(
|
||||
(await owner.readText(ws, escaped.entry)).text === escapedText,
|
||||
"real API 64 KiB escaped text must roundtrip without a false response-limit failure",
|
||||
);
|
||||
|
||||
// The adapter also handles real current authorization, not just fixture promises.
|
||||
await rejected(
|
||||
() =>
|
||||
createDriveClient(
|
||||
authenticatedFetch(fixture.origin, "invalid-fixture-token"),
|
||||
).readText(ws, fixture.worker_entry.entry),
|
||||
"denied",
|
||||
);
|
||||
console.log(
|
||||
"real Web adapter roundtrip passed: Worker visibility, folder/Markdown/PNG/empty bytes, CAS, stable URL lifecycle, authorization",
|
||||
);
|
||||
Reference in New Issue
Block a user