feat: merge durable Workdir updates from develop

# Conflicts:
#	docs/README.md
#	docs/design/durable-operations.md
#	web/workspace/deno.json
This commit is contained in:
2026-09-03 02:47:50 +09:00
43 changed files with 6467 additions and 524 deletions
+22
View File
@@ -1438,6 +1438,28 @@ mod tests {
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
}
#[test]
fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
let feature = resolved.manifest.feature;
assert!(feature.worker.enabled);
assert!(!feature.worker.direct_spawn);
assert!(feature.manage_workdir.enabled);
assert!(feature.merge_request.show);
assert!(feature.merge_request.readiness_check);
assert!(feature.merge_request.complete);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
}
#[test]
fn profile_resolution_requires_runtime_worker_name() {
let tmp = TempDir::new().unwrap();
@@ -23,8 +23,9 @@ use workdir::{
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
WorkingDirectoryRemovalRequest as WorkdirRemovalRequest,
WorkingDirectoryRemovalResponse as WorkdirRemovalResponse,
};
use crate::feature::{
@@ -51,7 +52,7 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
const DELETE_DESCRIPTION: &str = "Delete one persistent Workdir by id through Backend Workspace API authority. Occupied, blocked, or dirty Workdirs requiring confirmation are rejected.";
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
#[derive(Clone, Debug)]
pub struct ManageWorkdirFeature {
@@ -484,12 +485,21 @@ impl WorkspaceHttpWorkdirBackend {
)?;
let workspace_id = encode_path_segment(self.workspace_id()?);
let workdir_path = encode_path_segment(workdir_id);
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
body: None,
})?;
workdir_output(format!("Deleted Workdir {workdir_id}"), &response)
let response = self.execute_json::<WorkdirRemovalResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Delete,
format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
serde_json::to_string(&WorkdirRemovalRequest {
reason: validate_delete_reason(&input.reason)?.to_string(),
})
.map_err(decode_error)?,
))?;
workdir_output(
format!(
"Workdir {workdir_id} removal disposition: {:?}",
response.disposition
),
&response,
)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
@@ -611,6 +621,17 @@ fn validate_identity<'a>(
Ok(value)
}
fn validate_delete_reason(reason: &str) -> Result<&str, ToolError> {
let reason = reason.trim();
if reason.is_empty() || reason.len() > 500 || reason.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(
"WorkdirDelete reason must be non-empty, contain no control characters, and be at most 500 bytes"
.to_string(),
));
}
Ok(reason)
}
fn validate_optional_selector(selector: Option<String>) -> Result<Option<String>, ToolError> {
let Some(selector) = selector else {
return Ok(None);
@@ -689,9 +710,10 @@ fn delete_schema() -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["working_directory_id"],
"required": ["working_directory_id", "reason"],
"properties": {
"working_directory_id": {"type": "string", "minLength": 1}
"working_directory_id": {"type": "string", "minLength": 1},
"reason": {"type": "string", "minLength": 1, "maxLength": 500}
}
})
}
@@ -736,6 +758,7 @@ struct WorkdirAttachmentResponse {
#[serde(deny_unknown_fields)]
struct WorkdirDeleteInput {
working_directory_id: String,
reason: String,
}
#[cfg(test)]
@@ -959,7 +982,10 @@ mod tests {
assert!(create["properties"].get("session_id").is_none());
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
assert!(attach_schema()["properties"].get("session_id").is_none());
assert_eq!(delete_schema()["required"], json!(["working_directory_id"]));
assert_eq!(
delete_schema()["required"],
json!(["working_directory_id", "reason"])
);
}
#[test]
@@ -999,15 +1025,9 @@ mod tests {
"attached": false
})),
response(json!({
"workspace_id": "workspace/test",
"runtime_id": "runtime/one",
"item": {
"working_directory_id": "wd-created",
"repository_id": "main",
"materializer_kind": "local_git_worktree",
"status": "not_found"
},
"diagnostics": []
"disposition": "removed",
"retryable": false
})),
]));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
@@ -1052,6 +1072,7 @@ mod tests {
backend
.delete(WorkdirDeleteInput {
working_directory_id: "wd-created".to_string(),
reason: "remove stale Workdir".to_string(),
})
.unwrap();
@@ -1087,6 +1108,9 @@ mod tests {
"/api/w/workspace%2Ftest/working-directories/wd-created"
);
assert_eq!(requests[4].method, WorkspaceRequestMethod::Delete);
let body: serde_json::Value =
serde_json::from_str(requests[4].body.as_deref().unwrap()).unwrap();
assert_eq!(body, json!({"reason": "remove stale Workdir"}));
}
#[tokio::test]
+18
View File
@@ -920,4 +920,22 @@ mod tests {
);
}
}
#[test]
fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() {
let rendered = PromptCatalog::builtins_only()
.unwrap()
.render_name("role.orchestrator", Value::UNDEFINED)
.unwrap();
assert!(rendered.contains("This policy governs naming only"));
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
assert!(rendered.contains("call `WorkerStop`"));
assert!(rendered.contains("call `WorkerRemove`"));
assert!(rendered.contains("only then call `WorkdirDelete`"));
assert!(rendered.contains("`CurrentAssignment` means unassign and reread"));
assert!(!rendered.contains("{% include"));
}
}
+40
View File
@@ -9,6 +9,7 @@ use agen::llm_client::{ClientError, LlmClient, Request};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use futures::{Stream, StreamExt};
use manifest::{ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry};
use workdir::{
@@ -248,6 +249,14 @@ async fn make_worker_with_pwd_manifest_and_workspace_context(
workspace_context: WorkerWorkspaceContext,
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
let manifest = WorkerManifest::from_toml(manifest_toml).unwrap();
make_worker_with_manifest_and_workspace_context(client, manifest, workspace_context).await
}
async fn make_worker_with_manifest_and_workspace_context(
client: MockClient,
manifest: WorkerManifest,
workspace_context: WorkerWorkspaceContext,
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
let store_tmp = tempfile::tempdir().unwrap();
let store = CombinedStore::new(
FsStore::new(store_tmp.path()).unwrap(),
@@ -783,6 +792,37 @@ permission = "write"
}
}
#[tokio::test]
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
let workspace = tempfile::tempdir().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
let workspace_context =
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let (worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
resolved.manifest,
workspace_context,
)
.await;
let handle = spawn_controller(worker).await;
handle.send(Method::run_text("Hello")).await.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
let request = wait_for_captured_request(&client_for_assert).await;
let installed = request_tool_names(&request);
assert!(installed.iter().any(|name| name == "WorkerRemove"));
assert!(installed.iter().any(|name| name == "WorkdirDelete"));
}
#[tokio::test]
async fn worker_and_sub_worker_features_install_one_canonical_control_surface() {
let manifest = r#"
+214
View File
@@ -222,6 +222,100 @@ pub struct WorkspaceResponse {
pub extension_points: WorkspaceExtensionPoints,
}
/// Workspace identity metadata exposed by the current settings resource.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetadataSettingsResponse {
pub workspace_id: String,
pub display_name: String,
pub created_at: String,
pub revision: String,
pub source: String,
pub diagnostics: Vec<Diagnostic>,
}
/// Compare-and-swap update for Workspace identity display metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct UpdateWorkspaceMetadataRequest {
pub display_name: String,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceMetadataMutationResponse {
pub workspace: WorkspaceMetadataSettingsResponse,
pub diagnostics: Vec<Diagnostic>,
}
/// Read-only Profile catalog projected from one active Workspace config revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct ProfileSettingsResponse {
pub workspace_id: String,
pub registry_revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
pub config_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceProfileSummary {
pub profile_id: String,
pub selector: String,
pub label: String,
pub source_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_source_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub editable: bool,
pub is_default: bool,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String,
pub display_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub size_bytes: u64,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -397,6 +491,34 @@ pub struct WorkingDirectoryCleanupTarget {
pub repository_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryRemovalRequest {
pub reason: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryRemovalDisposition {
Removed,
Retained,
AttentionRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryRemovalResponse {
pub working_directory_id: String,
pub disposition: WorkingDirectoryRemovalDisposition,
pub retryable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_category: Option<String>,
}
/// Durable Workspace occupancy projection for one Workdir.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -1096,6 +1218,13 @@ pub fn catalog_typescript() -> String {
WorkspaceExtensionPointState::decl(&config),
WorkspaceExtensionPoints::decl(&config),
WorkspaceResponse::decl(&config),
WorkspaceMetadataSettingsResponse::decl(&config),
UpdateWorkspaceMetadataRequest::decl(&config),
WorkspaceMetadataMutationResponse::decl(&config),
ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config),
WorkspaceProfileSourceProvenance::decl(&config),
RepositorySourceKind::decl(&config),
RepositorySource::decl(&config),
RepositoryObservedStatus::decl(&config),
@@ -1294,6 +1423,14 @@ mod tests {
assert!(output.contains("export type RepositoryListResponse ="));
assert!(output.contains("items: Array<RepositorySummary>"));
assert!(output.contains("observed_at?: string | null"));
assert!(output.contains("export type WorkspaceMetadataSettingsResponse ="));
assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
assert!(output.contains("export type ProfileSettingsResponse ="));
assert!(output.contains("config_revision?: number | null"));
assert!(output.contains("provenance: WorkspaceProfileSourceProvenance"));
assert!(output.contains(
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
));
assert!(!output.contains("repository_id: string, display_name"));
}
@@ -1326,6 +1463,83 @@ mod tests {
assert_eq!(decoded, value);
}
#[test]
fn workspace_metadata_and_profile_projection_fixtures_round_trip() {
let diagnostic = Diagnostic {
code: "profile_projection_warning".to_string(),
severity: DiagnosticSeverity::Warning,
message: "projected from the active config revision".to_string(),
};
let metadata = WorkspaceMetadataSettingsResponse {
workspace_id: "workspace-test".to_string(),
display_name: "Test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
revision: "sha256:metadata".to_string(),
source: "workspace-config".to_string(),
diagnostics: vec![diagnostic.clone()],
};
round_trip(metadata.clone());
round_trip(UpdateWorkspaceMetadataRequest {
display_name: "Renamed".to_string(),
revision: metadata.revision.clone(),
});
round_trip(WorkspaceMetadataMutationResponse {
workspace: metadata,
diagnostics: vec![],
});
round_trip(ProfileSettingsResponse {
workspace_id: "workspace-test".to_string(),
registry_revision: "config-source:7:sha256:tree:sha256:projection".to_string(),
config_revision: Some(7),
tree_digest: Some("sha256:tree".to_string()),
projection_digest: Some("sha256:projection".to_string()),
default_profile: Some("workspace:coder".to_string()),
profiles: vec![WorkspaceProfileSummary {
profile_id: "workspace:coder".to_string(),
selector: "workspace:coder".to_string(),
label: "Coder".to_string(),
source_kind: "project".to_string(),
profile_source_id: Some("profile-source-1".to_string()),
description: None,
editable: true,
is_default: true,
diagnostics: vec![diagnostic.clone()],
}],
sources: vec![WorkspaceProfileSourceSummary {
profile_source_id: "profile-source-1".to_string(),
display_path: "profiles/coder.dcdl".to_string(),
kind: "profile".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: "sha256:source".to_string(),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: "config-source:7".to_string(),
size_bytes: 128,
diagnostics: vec![],
}],
diagnostics: vec![diagnostic],
});
let absent_optional_fields = serde_json::json!({
"workspace_id": "workspace-test",
"registry_revision": "builtin",
"profiles": [],
"sources": [],
"diagnostics": []
});
let decoded: ProfileSettingsResponse =
serde_json::from_value(absent_optional_fields.clone()).unwrap();
assert_eq!(decoded.config_revision, None);
assert_eq!(decoded.tree_digest, None);
assert_eq!(decoded.projection_digest, None);
assert_eq!(decoded.default_profile, None);
assert_eq!(
serde_json::to_value(decoded).unwrap(),
absent_optional_fields
);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker {
+1
View File
@@ -30,6 +30,7 @@ pub mod server;
pub mod skills;
pub mod store;
pub mod workdir_create_operations;
mod workdir_removal;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_subscription;
@@ -12,11 +12,15 @@ use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use workspace_api::{
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
};
use crate::config_source::{
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
};
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
@@ -427,81 +431,6 @@ fn build_virtual_profile_archive(
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceMetadataSettingsResponse {
pub workspace_id: String,
pub display_name: String,
pub created_at: String,
pub revision: String,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateWorkspaceMetadataRequest {
pub display_name: String,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceMetadataMutationResponse {
pub workspace: WorkspaceMetadataSettingsResponse,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSettingsResponse {
pub workspace_id: String,
pub registry_revision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSummary {
pub profile_id: String,
pub selector: String,
pub label: String,
pub source_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_source_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub editable: bool,
pub is_default: bool,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String,
pub display_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceIdentityFile {
@@ -804,8 +733,8 @@ fn diagnostic(
code: impl Into<String>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> RuntimeDiagnostic {
RuntimeDiagnostic {
) -> Diagnostic {
Diagnostic {
code: code.into(),
severity,
message: message.into(),
File diff suppressed because it is too large Load Diff
+154 -22
View File
@@ -267,6 +267,11 @@ const MIGRATIONS: &[Migration] = &[
name: "require one account owner for every Workspace",
apply: require_workspace_account_owner,
},
Migration {
version: 49,
name: "create durable Workdir removal operations",
apply: crate::workdir_removal::create_workdir_removal_operations,
},
];
struct Migration {
@@ -4884,6 +4889,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"Workdir {workdir_id} is not registered in Workspace {workspace_id}"
)));
}
let removal_pending: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM workdir_removal_operations
WHERE workspace_id = ?1 AND workdir_id = ?2 AND state = 'pending'
)"#,
params![workspace_id, workdir_id],
|row| row.get(0),
)?;
if removal_pending {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir {workdir_id} has a pending durable removal operation"
)));
}
let occupied: bool = tx.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM worker_workdir_links
@@ -10085,6 +10103,120 @@ mod tests {
.unwrap();
}
#[test]
fn schema_v49_upgrades_persisted_v48_workdir_fixture() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server-v48.db");
{
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations_through(&conn, 48).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert!(!table_exists(&conn, "workdir_removal_operations").unwrap());
conn.execute_batch(
r#"
INSERT INTO accounts (
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces (
workspace_id, owner_account_id, display_name, state, created_at, updated_at
) VALUES ('workspace-a', 'owner-account', 'Workspace A', 'active', '1', '1');
INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (
'workspace-a', 'repository-a', 'Repository A', 'git', 'local', '/repo-a',
'local_path', '/repo-a', 'develop', 1,
'sha256:source-a', 'unverified', NULL, '1', '1'
);
INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree,
observed_at_epoch_seconds, materialization_status, cleanliness,
created_at, updated_at
) VALUES (
'workspace-a', 'workdir-a', 'runtime-a', 'repository-a',
'refs/heads/develop', 'abc', 'tree-a',
'refs/heads/work', 'def', 'tree-b',
1, 'present', 'clean', '1', '1'
);
"#,
)
.unwrap();
}
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 49);
assert!(table_exists(conn, "workdir_removal_operations")?);
let columns = table_columns(conn, "workdir_removal_operations")?;
for required in [
"workspace_id",
"operation_id",
"request_fingerprint",
"workdir_id",
"runtime_id",
"repository_id",
"materialization_fingerprint",
"source_actor",
"reason",
"state",
"attempt_count",
"retryable",
"disposition",
"failure_category",
"attempt_owner_pid",
"attempt_owner_start_marker",
"created_at",
"updated_at",
"completed_at",
] {
assert!(
columns.iter().any(|column| column == required),
"missing {required}"
);
}
let preserved: (String, String, String) = conn.query_row(
"SELECT workspace_id, repository_id, materialization_status FROM workdir_registry WHERE workdir_id='workdir-a'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(
preserved,
(
"workspace-a".to_string(),
"repository-a".to_string(),
"present".to_string(),
)
);
let foreign_key_failures: i64 = conn.query_row(
"SELECT count(*) FROM pragma_foreign_key_check",
[],
|row| row.get(0),
)?;
assert_eq!(foreign_key_failures, 0);
Ok(())
})
.unwrap();
let workdir = store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.unwrap();
let intent = crate::workdir_removal::workdir_removal_intent(
&workdir,
"migration-test",
"remove migrated Workdir",
)
.unwrap();
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
assert_eq!(operation.workspace_id, "workspace-a");
assert_eq!(operation.working_directory_id, "workdir-a");
}
#[test]
fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() {
let conn = Connection::open_in_memory().unwrap();
@@ -10118,7 +10250,7 @@ mod tests {
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let remote = conn
.query_row(
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
@@ -10197,7 +10329,7 @@ mod tests {
let before = std::fs::read(&path).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 36);
assert_eq!(plan.target_schema_version, 48);
assert_eq!(plan.target_schema_version, 49);
assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
@@ -10211,7 +10343,7 @@ mod tests {
store
.with_conn(|conn| {
assert!(table_exists(conn, "worker_diagnostics_archives")?);
assert_eq!(current_schema_version(conn)?, 48);
assert_eq!(current_schema_version(conn)?, 49);
Ok(())
})
.unwrap();
@@ -10351,7 +10483,7 @@ mod tests {
),
]
);
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let foreign_key_error: Option<String> = conn
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
.optional()
@@ -10481,7 +10613,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let controller_worker_id: String = conn
.query_row(
@@ -10599,7 +10731,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
}
@@ -10618,7 +10750,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let settings = conn
.query_row(
"SELECT settings_revision, language FROM workspace_memory_settings \
@@ -10659,7 +10791,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -10727,7 +10859,7 @@ INSERT INTO worker_workdir_attachment_reservations (
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let repositories_sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -10910,7 +11042,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
assert!(
!store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -10927,7 +11059,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 48);
assert_eq!(reopened.schema_version().await.unwrap(), 49);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@@ -11693,7 +11825,7 @@ INSERT INTO worker_registry (
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
migrated
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 48);
assert_eq!(current_schema_version(conn)?, 49);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
1,
@@ -12044,7 +12176,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 44);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
assert!(table_exists(&conn, "workdir_create_operations").unwrap());
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
@@ -12071,7 +12203,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 45);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
for table in [
"repository_ssh_credentials",
"repository_ssh_credential_revisions",
@@ -12098,7 +12230,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 46);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
"source_kind",
@@ -12132,13 +12264,13 @@ INSERT INTO worker_registry (
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'future')",
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'future')",
[],
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 49 is newer"), "{error}");
assert!(error.contains("schema version 50 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}");
}
@@ -12362,7 +12494,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
assign_explicit_test_workspace_owner(&conn);
apply_migrations(&mut conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 48);
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let workspace_id: Option<String> = conn
.query_row(
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
@@ -12991,7 +13123,7 @@ WHERE workspace_id = 'workspace-a'
assign_explicit_test_workspace_owner(&conn);
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
store
.with_conn(|conn| {
@@ -13180,7 +13312,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(),
@@ -13258,7 +13390,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(),
@@ -13671,7 +13803,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 48);
assert_eq!(store.schema_version().await.unwrap(), 49);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),
@@ -1,4 +1,4 @@
use rusqlite::{OptionalExtension, params};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord;
@@ -103,6 +103,65 @@ impl SqliteWorkspaceStore {
})
}
pub fn begin_failed_workdir_create_retry(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before retry"
))
})?;
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if operation.state != "failed" {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir create operation `{operation_id}` is not a failed retry"
)));
}
let removal_pending: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending')",
params![workspace_id, operation.working_directory_id],
|row| row.get(0),
)?;
if removal_pending {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir {} has a pending durable removal operation",
operation.working_directory_id
)));
}
let changed = tx.execute(
r#"UPDATE workdir_create_operations
SET state='pending', failure=NULL, updated_at=?1
WHERE workspace_id=?2 AND operation_id=?3
AND request_fingerprint=?4 AND state='failed'"#,
params![updated_at, workspace_id, operation_id, request_fingerprint],
)?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir create operation `{operation_id}` retry was claimed concurrently"
)));
}
let updated = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after retry claim"
))
})?;
tx.commit()?;
Ok(updated)
})
}
pub fn bind_workdir_create_repository_access(
&self,
workspace_id: &str,
@@ -406,11 +465,32 @@ mod tests {
.unwrap();
assert_eq!(replayed, bound);
assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
let failed = store
.finish_workdir_create_operation(
"workspace",
"call-1",
&record.request_fingerprint,
false,
Some("provider failed"),
"2026-08-24T00:00:03Z",
)
.unwrap();
assert_eq!(failed.state, "failed");
let retry = store
.begin_failed_workdir_create_retry(
"workspace",
"call-1",
&record.request_fingerprint,
"2026-08-24T00:00:04Z",
)
.unwrap();
assert_eq!(retry.state, "pending");
assert_eq!(retry.failure, None);
assert_eq!(
store
.load_workdir_create_operation("workspace", "call-1")
.unwrap(),
Some(bound.clone())
Some(retry.clone())
);
let mut changed_input = record.clone();
changed_input.request_fingerprint =
File diff suppressed because it is too large Load Diff
+4
View File
@@ -4,11 +4,15 @@ pkgs.mkShell {
nixfmt
deno
git
playwright-driver.browsers
rustc
cargo
pkgs.sccache
];
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
SCCACHE_CACHE_SIZE = "5G";
+1 -1
View File
@@ -7,7 +7,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
## Reading order
1. [`design/overview.md`](design/overview.md) — the system map.
2. [`design/durable-operations.md`](design/durable-operations.md) — cross-domain operation identity, checkpoints, retries, child operations, and disposition.
2. [`design/durable-operations.md`](design/durable-operations.md) — cross-domain operation identity, checkpoints, retries, child operations, and disposition, including durable Workdir removal.
3. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
4. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
5. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
+47
View File
@@ -254,6 +254,53 @@ compensation has its own external side effects or retry lifecycle, give it a
stable child operation identity rather than expanding the parent into a list of
cleanup stages.
## Workdir removal application
Workdir removal is one durable side-effect operation in the Workspace Server
DB. It binds the Workspace, Workdir, owning Runtime,
Repository/materialization identity, source actor, stable intent fingerprint,
lifecycle, retry metadata, and bounded result. Runtime URL, provider handle,
host path, credentials, and caller-selected Runtime are not operation inputs.
A durable one-pending-operation constraint plus an atomic attempt claim prevents
concurrent callers from entering the provider side effect for the same Workdir;
the in-process resource lock is an additional serialization layer, not the sole
authority. Each active attempt persists the Server process ID and process-start
marker. Recovery reclaims only an owner proven missing or replaced; a live or
unobservable owner is never stolen. The reclaim transaction compare-and-set
checks the exact proved owner snapshot and attempt count so stale orphan proof
cannot overwrite a newer live claim.
Each attempt:
1. resolves or revalidates the persisted same-Workspace Workdir, Runtime,
Repository, and materialization identity;
2. checks current attachments, attachment reservations, current assignment
occupancy, retention/cleanup holds, and pending materialization authority; a
failed Workdir-create retry must atomically return to `pending` before
provider work and is rejected while removal is pending;
3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without
detaching a Worker or forcing deletion;
4. observes the owning Runtime/provider and calls its existing Workdir cleanup
only for an eligible clean Workdir;
5. treats only successful provider cleanup or exact
`working_directory_not_found` as removal evidence;
6. deletes the Backend Workdir registry row and commits the operation's
`completed`/`removed` result in one SQLite transaction.
A provider error leaves the registry intact and records a bounded
`attention_required` result with explicit retryability. Startup recovery lists
`pending` and retryable `failed` operations, then executes this same path after
rereading live authority. `WorkdirDelete`, Workspace REST removal, Runtime
cleanup execution, and recovery must not maintain separate inline
provider-delete paths.
The public request contains only `working_directory_id` plus a bounded reason.
The public result contains only the Workdir ID,
`removed | retained | attention_required`, retryability, and an optional bounded
failure category. Internal operation identifiers, checkpoints, provider paths,
and credentials are not public DTO fields.
## Diagnostics and audit
Persist bounded error categories and identifiers needed to investigate or retry.
+4
View File
@@ -23,3 +23,7 @@ Do not create or delegate an implementation worktree/branch until the Ticket rec
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After `CompleteMergeRequest` and Ticket completion, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
+191
View File
@@ -0,0 +1,191 @@
# Web UX inspection workbench
`tools/web-ux` is a development-only Playwright workbench for repeatable visual inspection of the
real Web Workspace. It does not add a Yoi product Skill, Flow, Runtime capability, or browser
automation route.
The workbench produces a **review context bundle** rather than treating a screenshot as evidence by
itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data
state, sanitized document URL/status, console/page/request failures, screenshot hashes, an
accessibility snapshot, source revision, and browser version.
## Environment
Enter the repository dev shell. The shell supplies the Nix-pinned Chromium build and sets
`PLAYWRIGHT_BROWSERS_PATH`; Playwright does not download a browser at runtime.
```sh
nix develop
cd tools/web-ux
deno task check
deno task test
deno task test:browser
```
`test:browser` starts a deterministic fixture server owned by the test, captures distinct owner and
non-owner contexts, verifies the review bundle, and proves server/browser cleanup. It must run
inside `nix develop` so it uses the pinned browser.
The npm Playwright version in `deno.json` must match `pkgs.playwright-driver.version` in the pinned
Nixpkgs input. Update both as one toolchain change.
## Scenario contract
Scenarios are reviewed JSON files under `scenarios/`. A scenario fixes:
- personas and whether each uses an isolated anonymous context or a local Playwright storage-state
file;
- explicit routes and user goals;
- expected data state, viewports, theme, locale, timezone, and reduced-motion mode;
- an explicit readiness condition for every route and optional interaction/capture-point conditions;
- selectors and exact environment-derived text that must be redacted;
- optional processes owned by the capture command, including an HTTP readiness URL.
`${UPPER_CASE_ENV}` values are expanded at runtime. URLs with embedded credentials are rejected.
Route readiness is bounded and retried twice; it never relies on a fixed sleep. `network-idle` is
available but should be used only for screens whose contract actually reaches idle. Prefer a stable
screen-owned selector.
`workspace-control-plane.json` expects:
```sh
export WEB_UX_BASE_URL='http://127.0.0.1:5173'
export WORKSPACE_ID='<workspace-id>'
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
```
## Authentication fixtures
Authentication state is local sensitive material stored under `$XDG_STATE_HOME/yoi/web-ux/auth/`,
outside the Repository and Workdir. Files are written with mode `0600`, state contents are never
copied into a review bundle, and the CLI never prints cookies or credentials. Each profile has a
sidecar binding it to the exact persona and base URL origin with a 12-hour default expiry. Capture
fails explicitly when metadata is missing, the origin differs, or the profile has expired; it never
silently reuses or refreshes that state.
For an interactive Passkey/browser login:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner
```
The command opens Chromium at the configured login route, waits up to five minutes for the
scenario's success URL, saves `storageState`, and closes the browser in `finally`. Repeat for
`non-owner` using a real account with that permission projection.
A test fixture may already provide Playwright-compatible `{ cookies, origins }` state. Import it
without putting its value on the command line:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--import-state /private/path/owner-state.json \
--expires-in-hours 8
```
Delete both the profile and its metadata when it is no longer needed:
```sh
deno task web-ux auth \
--scenario scenarios/workspace-control-plane.json \
--persona owner \
--delete
```
Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a
scenario, process arguments, a Repository URL, or `redact.text`. `redact.text` is only a final
defense for a secret already supplied through an environment-owned fixture; it is not a credential
transport.
## Capture and inspect
Capture a stable multi-persona bundle:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../target/web-ux \
--run-id before-change
```
Use filters for a bounded feedback loop:
```sh
deno task web-ux capture \
--scenario scenarios/workspace-control-plane.json \
--output ../../target/web-ux \
--run-id ticket-list-after \
--personas owner,non-owner \
--routes tickets \
--viewports desktop
```
The command exits `2` when it produced evidence but observed UI/tool errors, and exits `1` when
capture itself failed. It continues other route/persona captures after a bounded route failure.
Inspect:
- `review-context.json` for the exact context, hashes, HTTP status, retained/truncated diagnostic
counts, route and capture-point readiness, and the redacted interaction sequence;
- `contact-sheet.png` through its manifest `workdirPath` with an image-capable reviewer for
composition, hierarchy, density, clipping, empty/error states, and permission-specific
affordances;
- each `accessibility.md` through its manifest `workdirPath` for landmark/name/state evidence that a
screenshot cannot prove;
- `process-logs/` when the scenario owns a server process. Each stdout/stderr stream is redacted,
capped at 1 MiB, and paired with truncation metadata.
The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record
concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect
the new evidence. Playwright success alone is not visual acceptance.
## Compare before and after
```sh
deno task web-ux compare \
--before ../../target/web-ux/before-change/review-context.json \
--after ../../target/web-ux/after-change/review-context.json \
--output ../../target/web-ux/before-vs-after
```
`comparison.html` and `comparison.png` show before, after, and pixel diff side by side.
`comparison.json` records changed-pixel counts, dimension mismatches, unmatched capture keys, and
diff hashes. Pixel differences are orientation evidence, not a correctness verdict; explain expected
animation/font/data changes and inspect the actual UI.
Capture keys are stable across runs: `persona / route / viewport / capture-point`. Keep those
identities unchanged when comparing the same user task.
## Process and artifact cleanup
The capture command owns only processes declared in its scenario. It starts them without a shell,
records bounded/redacted output, and terminates the process and descendants on success, capture
failure, or interruption observed by the command. It never stops an existing Yoi Server or Runtime
that it did not start.
Old complete review bundles can be removed without touching auth state or arbitrary directories:
```sh
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run
deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14
```
Cleanup recognizes only directories containing `review-context.json`. The repository `target/` tree
is ignored by Git, while authentication state remains outside the repository. `capture` defaults to
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
approved immutable artifact channel when durable review evidence is required.
## Adding a scenario
1. Name the concrete user task and expected data state; do not write “looks correct”.
2. Use the smallest persona/route/viewport matrix that proves the intended contract, including
owner/non-owner/anonymous boundaries when permissions affect composition.
3. Choose a screen-owned readiness selector or response. Avoid arbitrary sleeps.
4. Add capture points only for meaningful visual states (initial, expanded detail, error, empty, and
so on).
5. Mark sensitive DOM regions with `[data-web-ux-redact]` or scenario selectors; never use review
artifacts to transport secrets.
6. Run `deno task check`, `deno task test`, one real capture, and inspect `contact-sheet.png` plus
`review-context.json`.
@@ -0,0 +1,133 @@
import { assertEquals, assertRejects } from "@std/assert";
import { join } from "@std/path";
import { writeAuthMetadata } from "../src/auth_state.ts";
import { capture } from "../src/capture.ts";
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => {
const directory = await Deno.makeTempDir();
const previousSecret = Deno.env.get("WEB_UX_FIXTURE_SECRET");
const fixtureSecret = "fixture-canary-secret";
Deno.env.set("WEB_UX_FIXTURE_SECRET", fixtureSecret);
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
try {
const authDirectory = join(directory, "auth");
await Deno.mkdir(authDirectory);
for (const persona of ["owner", "non-owner"]) {
const storageState = join(authDirectory, `${persona}.json`);
await Deno.writeTextFile(
storageState,
JSON.stringify({
cookies: [{
name: "persona",
value: persona,
domain: "127.0.0.1",
path: "/",
expires: -1,
httpOnly: true,
secure: false,
sameSite: "Lax",
}],
origins: [],
}),
);
await writeAuthMetadata(storageState, persona, baseUrl, 1);
}
const scenarioPath = join(directory, "scenario.json");
await Deno.writeTextFile(
scenarioPath,
JSON.stringify({
schemaVersion: 1,
id: "browser-smoke",
title: "Browser smoke",
baseUrl,
redact: {
selectors: ["[data-web-ux-redact]"],
text: ["${WEB_UX_FIXTURE_SECRET}"],
},
personas: [
{ id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } },
{
id: "non-owner",
label: "Non-owner",
auth: { kind: "storage-state", path: "auth/non-owner.json" },
},
],
viewports: [{ label: "desktop", width: 1000, height: 700 }],
routes: [{
id: "repositories",
label: "Repositories",
path: "/screen",
goal: "Verify permission-specific composition",
dataState: "Deterministic fixture repository",
ready: { kind: "selector", selector: "main" },
capturePoints: [{
id: "initial",
label: "Initial",
interaction: [{
action: "wait",
ready: { kind: "selector", selector: "h1" },
}],
}],
}],
processes: [{
id: "fixture-server",
command: Deno.execPath(),
args: [
"run",
"--allow-env",
"--allow-net",
join(Deno.cwd(), "browser-tests/fixture_server.ts"),
String(port),
],
env: { WEB_UX_FIXTURE_SECRET: "${WEB_UX_FIXTURE_SECRET}" },
readyUrl: `${baseUrl}/health`,
}],
}),
);
const manifest = await capture({
scenarioPath,
outputDirectory: join(directory, "artifacts"),
runId: "multi-persona",
});
assertEquals(manifest.status, "completed-with-errors");
assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]);
assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true);
assertEquals(manifest.captures[0].route.ready.kind, "selector");
assertEquals(manifest.captures[0].interactions[0].action, "wait");
assertEquals(manifest.captures[0].errorSummary, {
observed: 150,
retained: 100,
truncated: true,
limit: 100,
});
assertEquals(manifest.contactSheet.png?.bundlePath, "contact-sheet.png");
const runDirectory = join(directory, "artifacts", "multi-persona");
const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json"));
assertEquals(reviewContext.includes('"cookies"'), false);
assertEquals(reviewContext.includes(fixtureSecret), false);
const processLog = await Deno.readTextFile(
join(runDirectory, "process-logs", "fixture-server.stdout.log"),
);
assertEquals(processLog.includes(fixtureSecret), false);
if (Deno.build.os !== "windows") {
const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].bundlePath);
assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600);
}
await assertRejects(
() => fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) }),
TypeError,
);
} finally {
if (previousSecret === undefined) Deno.env.delete("WEB_UX_FIXTURE_SECRET");
else Deno.env.set("WEB_UX_FIXTURE_SECRET", previousSecret);
await Deno.remove(directory, { recursive: true });
}
});
@@ -0,0 +1,20 @@
const port = Number(Deno.args[0]);
if (!Number.isInteger(port) || port <= 0) throw new Error("port is required");
const canary = Deno.env.get("WEB_UX_FIXTURE_SECRET") ?? "";
console.log(`Authorization: Bearer ${canary}`);
Deno.serve({ hostname: "127.0.0.1", port }, (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const cookie = request.headers.get("cookie") ?? "";
const owner = cookie.includes("persona=owner");
const title = owner ? "Owner repository settings" : "Repository settings";
const action = owner
? '<button type="button">Add repository</button>'
: '<p role="note">Ask a Workspace owner to change repository access.</p>';
return new Response(
`<!doctype html><html><head><title>${title}</title><style>body{font:16px system-ui;margin:0}main{max-width:800px;margin:40px auto}header{border-bottom:1px solid #ccc;padding:16px}section{border:1px solid #ccc;padding:20px}button{background:#06c;color:white;padding:10px 20px}</style></head><body><header>Workspace</header><main><h1>${title}</h1><section><h2>main</h2><p>SSH repository access is configured.</p>${action}<span data-web-ux-redact>${canary}</span><script>for(let index=0;index<150;index++)console.error('fixture error '+index)</script></section></main></body></html>`,
{ headers: { "content-type": "text/html; charset=utf-8" } },
);
});
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys
import { dirname, fromFileUrl, resolve } from "@std/path";
import { authenticate, cleanup } from "./src/lifecycle.ts";
import { capture, describeCapture } from "./src/capture.ts";
import { compare } from "./src/compare.ts";
const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux");
const HELP = `Web UX inspection workbench
Usage:
deno task web-ux auth --scenario <file> --persona <id> [--base-url <url>] [--import-state <file>] [--expires-in-hours <hours>] [--headless]
deno task web-ux auth --scenario <file> --persona <id> --delete
deno task web-ux capture --scenario <file> [--output <directory>] [--base-url <url>] [--run-id <id>] [--personas <ids>] [--routes <ids>] [--viewports <ids>] [--headed]
deno task web-ux compare --before <review-context.json> --after <review-context.json> --output <directory> [--threshold <0..1>]
deno task web-ux cleanup --output <directory> [--keep <count>] [--older-than-days <days>] [--dry-run]
Comma-separate persona, route, and viewport ids. Auth state is local, mode 0600, and must not be committed.
`;
type Arguments = { command: string; values: Map<string, string[]>; flags: Set<string> };
export function parseArguments(args: string[]): Arguments {
const command = args.shift() ?? "help";
const values = new Map<string, string[]>();
const flags = new Set<string>();
for (let index = 0; index < args.length; index++) {
const token = args[index];
if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
const name = token.slice(2);
const next = args[index + 1];
if (next === undefined || next.startsWith("--")) {
flags.add(name);
} else {
const items = values.get(name) ?? [];
items.push(next);
values.set(name, items);
index++;
}
}
return { command, values, flags };
}
function optional(args: Arguments, name: string): string | undefined {
const values = args.values.get(name);
if (!values) return undefined;
if (values.length !== 1) throw new Error(`--${name} must be specified once`);
return values[0];
}
function required(args: Arguments, name: string): string {
const value = optional(args, name);
if (!value) throw new Error(`--${name} is required`);
return value;
}
function integer(args: Arguments, name: string, fallback?: number): number | undefined {
const value = optional(args, name);
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`--${name} must be a non-negative integer`);
}
return parsed;
}
function list(args: Arguments, name: string): string[] | undefined {
const value = optional(args, name);
return value?.split(",").map((item) => item.trim()).filter(Boolean);
}
function rejectUnknown(args: Arguments, allowedValues: string[], allowedFlags: string[]): void {
for (const name of args.values.keys()) {
if (!allowedValues.includes(name)) throw new Error(`unsupported option: --${name}`);
}
for (const name of args.flags) {
if (!allowedFlags.includes(name)) throw new Error(`unsupported flag: --${name}`);
}
}
export async function main(rawArgs: string[]): Promise<number> {
const args = parseArguments([...rawArgs]);
if (args.command === "help" || args.flags.has("help")) {
console.log(HELP);
return 0;
}
if (args.command === "auth") {
rejectUnknown(
args,
["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"],
["headless", "delete"],
);
const deleting = args.flags.has("delete");
if (deleting && optional(args, "import-state")) {
throw new Error("--delete cannot be combined with --import-state");
}
const path = await authenticate({
scenarioPath: required(args, "scenario"),
personaId: required(args, "persona"),
baseUrl: optional(args, "base-url"),
importState: optional(args, "import-state"),
timeoutMs: integer(args, "timeout-ms"),
expiresInHours: integer(args, "expires-in-hours"),
delete: deleting,
headless: args.flags.has("headless"),
});
console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`);
return 0;
}
if (args.command === "capture") {
rejectUnknown(args, [
"scenario",
"output",
"base-url",
"run-id",
"personas",
"routes",
"viewports",
], ["headed"]);
const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT;
const manifest = await capture({
scenarioPath: required(args, "scenario"),
outputDirectory,
baseUrl: optional(args, "base-url"),
runId: optional(args, "run-id"),
personas: list(args, "personas"),
routes: list(args, "routes"),
viewports: list(args, "viewports"),
headed: args.flags.has("headed"),
});
console.log(describeCapture(manifest, outputDirectory));
return manifest.status === "completed" ? 0 : 2;
}
if (args.command === "compare") {
rejectUnknown(args, ["before", "after", "output", "threshold"], []);
const thresholdValue = optional(args, "threshold");
const threshold = thresholdValue === undefined ? undefined : Number(thresholdValue);
if (
threshold !== undefined && (!Number.isFinite(threshold) || threshold < 0 || threshold > 1)
) {
throw new Error("--threshold must be between 0 and 1");
}
const report = await compare({
before: required(args, "before"),
after: required(args, "after"),
outputDirectory: required(args, "output"),
threshold,
});
console.log(`comparison saved: ${report}`);
return 0;
}
if (args.command === "cleanup") {
rejectUnknown(args, ["output", "keep", "older-than-days"], ["dry-run"]);
const removed = await cleanup({
outputDirectory: required(args, "output"),
keep: integer(args, "keep", 5)!,
olderThanDays: integer(args, "older-than-days"),
dryRun: args.flags.has("dry-run"),
});
for (const path of removed) {
console.log(`${args.flags.has("dry-run") ? "would remove" : "removed"}: ${path}`);
}
return 0;
}
throw new Error(`unknown command: ${args.command}\n\n${HELP}`);
}
if (import.meta.main) {
try {
Deno.exit(await main(Deno.args));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Deno.exit(1);
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"lock": true,
"imports": {
"@std/assert": "jsr:@std/assert@1.0.19",
"@std/path": "jsr:@std/path@1.1.4",
"pixelmatch": "npm:pixelmatch@7.1.0",
"playwright": "npm:playwright@1.59.1",
"pngjs": "npm:pngjs@7.0.0"
},
"tasks": {
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
},
"fmt": {
"lineWidth": 100
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/path@1.1.4": "1.1.4",
"npm:pixelmatch@7.1.0": "7.1.0",
"npm:playwright@1.59.1": "1.59.1",
"npm:pngjs@7.0.0": "7.0.0"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"pixelmatch@7.1.0": {
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dependencies": [
"pngjs"
],
"bin": true
},
"playwright-core@1.59.1": {
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"bin": true
},
"playwright@1.59.1": {
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"pngjs@7.0.0": {
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1.0.19",
"jsr:@std/path@1.1.4",
"npm:pixelmatch@7.1.0",
"npm:playwright@1.59.1",
"npm:pngjs@7.0.0"
]
}
}
@@ -0,0 +1,37 @@
{
"schemaVersion": 1,
"id": "anonymous-entry",
"title": "Anonymous entry and authentication review",
"baseUrl": "${WEB_UX_BASE_URL}",
"locale": "en-US",
"timezone": "UTC",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{ "id": "anonymous", "label": "Anonymous visitor", "auth": { "kind": "anonymous" } }
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
{ "label": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 1 }
],
"routes": [
{
"id": "entry",
"label": "Authentication entry",
"path": "/",
"goal": "Understand the product and begin authentication without seeing Workspace-private content.",
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }]
},
{
"id": "account",
"label": "Account entry",
"path": "/account",
"goal": "Understand current authentication state and the available account action without Workspace-private content.",
"dataState": "Fresh browser context with no cookies, local storage, or session state.",
"ready": { "kind": "network-idle", "timeoutMs": 15000 },
"capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }]
}
]
}
@@ -0,0 +1,93 @@
{
"schemaVersion": 1,
"id": "workspace-control-plane",
"title": "Workspace control-plane owner and non-owner review",
"baseUrl": "${WEB_UX_BASE_URL}",
"locale": "en-US",
"timezone": "UTC",
"colorScheme": "light",
"reducedMotion": "reduce",
"redact": {
"selectors": ["[data-web-ux-redact]", "input[type=password]"],
"text": []
},
"personas": [
{
"id": "owner",
"label": "Workspace owner",
"auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" },
"login": { "path": "/", "successUrl": "/w/" }
},
{
"id": "non-owner",
"label": "Authenticated non-owner",
"auth": {
"kind": "storage-state",
"path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json"
},
"login": { "path": "/", "successUrl": "/w/" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 },
{ "label": "narrow", "width": 900, "height": 900, "deviceScaleFactor": 1 }
],
"routes": [
{
"id": "workspace-home",
"label": "Workspace overview",
"path": "/w/${WORKSPACE_ID}",
"goal": "Orient the user and expose the highest-value Workspace actions without internal authority noise.",
"dataState": "Dogfood Workspace with current Runtime and Ticket data.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Initial viewport", "fullPage": true }]
},
{
"id": "tickets",
"label": "Ticket lanes",
"path": "/w/${WORKSPACE_ID}/tickets",
"goal": "Scan actionable Ticket lanes and reach the primary authoring action in the initial viewport.",
"dataState": "Planning, ready, queued, in-progress, and completed Tickets from the selected Workspace.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 30000 },
"capturePoints": [{ "id": "initial", "label": "Loaded Ticket lanes", "fullPage": true }]
},
{
"id": "workers",
"label": "Workers",
"path": "/w/${WORKSPACE_ID}/workers",
"goal": "Find current Worker state and the new-Worker action without exposing transport internals as the primary content.",
"dataState": "Current Workspace Worker projection with mixed lifecycle states.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }]
},
{
"id": "settings",
"label": "Workspace settings",
"path": "/w/${WORKSPACE_ID}/settings",
"goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.",
"dataState": "Current permission projection for the selected Workspace.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }]
},
{
"id": "repositories",
"label": "Repository settings",
"path": "/w/${WORKSPACE_ID}/settings/repositories",
"goal": "Review repository access as an owner and verify non-owner composition does not expose unusable owner actions.",
"dataState": "Workspace repository catalog projected through current permissions.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }]
},
{
"id": "repository-access",
"label": "Repository access",
"path": "/w/${WORKSPACE_ID}/settings/repository-access",
"goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.",
"dataState": "Configured repository access bindings projected without credential bytes.",
"ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 },
"capturePoints": [
{ "id": "initial", "label": "Repository access settings", "fullPage": true }
]
}
]
}
+135
View File
@@ -0,0 +1,135 @@
import { dirname, relative, resolve } from "@std/path";
const SECRET_PATTERNS: RegExp[] = [
/\b(authorization|cookie|set-cookie|x-csrf-token)\b\s*[:=]\s*[^\s,;]+/gi,
/\b(bearer)\s+[a-z0-9._~+\/-]+=*/gi,
/\b(session|token|credential|password|passkey|private[_ -]?key)\b\s*[:=]\s*["']?[^\s,"'};]+/gi,
];
export function redactText(value: string, exactSecrets: string[] = []): string {
let result = value;
for (const secret of exactSecrets) {
if (secret) result = result.replaceAll(secret, "[REDACTED]");
}
for (const pattern of SECRET_PATTERNS) result = result.replaceAll(pattern, "$1=[REDACTED]");
return result;
}
export function bounded(value: string, maximum = 1000): string {
const normalized = value.replaceAll(/\s+/g, " ").trim();
return normalized.length <= maximum ? normalized : `${normalized.slice(0, maximum - 1)}`;
}
export function safeUrl(value: string, baseUrl?: string): string {
try {
const url = new URL(value, baseUrl);
url.username = "";
url.password = "";
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]");
url.hash = "";
return url.toString();
} catch {
return "[invalid-url]";
}
}
export function assertBundleIsSecretFree(serialized: string, exactSecrets: string[] = []): void {
const lower = serialized.toLowerCase();
for (const forbidden of ["authorization:", "set-cookie:", "cookie:", "bearer "]) {
if (lower.includes(forbidden)) {
throw new Error(`review bundle contains forbidden secret marker: ${forbidden}`);
}
}
for (const secret of exactSecrets) {
if (secret && serialized.includes(secret)) {
throw new Error("review bundle contains configured secret text");
}
}
}
export async function ensurePrivateDirectory(path: string): Promise<void> {
await Deno.mkdir(path, { recursive: true, mode: 0o700 });
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o700);
}
export async function makePrivate(path: string): Promise<void> {
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
}
export async function writePrivateJson(path: string, value: unknown): Promise<void> {
await ensurePrivateDirectory(dirname(path));
await Deno.writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600);
}
export function workdirLogicalPath(repositoryRoot: string, path: string): string | null {
const absolute = resolve(path);
const logical = relative(repositoryRoot, absolute);
if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) {
return logical || ".";
}
return null;
}
const TEXT_ARTIFACT_EXTENSIONS = [".json", ".md", ".html", ".log", ".txt"];
async function artifactFiles(root: string): Promise<string[]> {
const files: string[] = [];
const visit = async (directory: string) => {
for await (const entry of Deno.readDir(directory)) {
const path = resolve(directory, entry.name);
if (entry.isDirectory) await visit(path);
else if (entry.isFile) files.push(path);
}
};
await visit(root);
return files;
}
export async function assertReviewBundleIsSecretFree(
root: string,
exactSecrets: string[] = [],
): Promise<void> {
const secrets = exactSecrets.filter(Boolean);
const overlapLength = Math.max(256, ...secrets.map((secret) => secret.length + 1));
for (const path of await artifactFiles(root)) {
const isText = TEXT_ARTIFACT_EXTENSIONS.some((extension) => path.endsWith(extension));
const file = await Deno.open(path, { read: true });
const decoder = new TextDecoder();
let overlap = "";
try {
const buffer = new Uint8Array(64 * 1024);
while (true) {
const count = await file.read(buffer);
if (count === null) break;
const content = overlap + decoder.decode(buffer.subarray(0, count), { stream: true });
for (const secret of secrets) {
if (content.includes(secret)) {
throw new Error(
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
);
}
}
if (isText) assertBundleIsSecretFree(content, secrets);
overlap = content.slice(-overlapLength);
}
const final = overlap + decoder.decode();
for (const secret of secrets) {
if (final.includes(secret)) {
throw new Error(
`review bundle artifact contains configured secret text: ${relative(root, path)}`,
);
}
}
if (isText) assertBundleIsSecretFree(final, secrets);
} finally {
file.close();
}
}
}
export async function sha256File(path: string): Promise<string> {
const bytes = await Deno.readFile(path);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
+87
View File
@@ -0,0 +1,87 @@
import { writePrivateJson } from "./artifacts.ts";
export type AuthStateMetadata = {
schemaVersion: 1;
personaId: string;
baseOrigin: string;
createdAt: string;
expiresAt: string;
};
export function authMetadataPath(storageStatePath: string): string {
return `${storageStatePath}.meta.json`;
}
function baseOrigin(baseUrl: string): string {
return new URL(baseUrl).origin;
}
export async function writeAuthMetadata(
storageStatePath: string,
personaId: string,
baseUrl: string,
expiresInHours: number,
): Promise<void> {
if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) {
throw new Error("auth state expiry must be a positive number of hours");
}
const createdAt = new Date();
const metadata: AuthStateMetadata = {
schemaVersion: 1,
personaId,
baseOrigin: baseOrigin(baseUrl),
createdAt: createdAt.toISOString(),
expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(),
};
await writePrivateJson(authMetadataPath(storageStatePath), metadata);
}
export async function validateAuthState(
storageStatePath: string,
personaId: string,
baseUrl: string,
now = new Date(),
): Promise<void> {
await Deno.stat(storageStatePath);
let parsed: unknown;
try {
parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath)));
} catch (error) {
throw new Error(
`auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`auth state metadata is invalid for ${personaId}`);
}
const metadata = parsed as Partial<AuthStateMetadata>;
if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) {
throw new Error(`auth state metadata does not match persona ${personaId}`);
}
if (metadata.baseOrigin !== baseOrigin(baseUrl)) {
throw new Error(
`auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${
baseOrigin(baseUrl)
}`,
);
}
const expiresAt = Date.parse(metadata.expiresAt ?? "");
if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`);
if (expiresAt <= now.getTime()) {
throw new Error(
`auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`,
);
}
}
export async function deleteAuthState(storageStatePath: string): Promise<void> {
for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) {
try {
await Deno.remove(path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
}
+637
View File
@@ -0,0 +1,637 @@
import { basename, dirname, join, relative, resolve } from "@std/path";
import { type Browser, chromium, type Page, type Response } from "playwright";
import { validateAuthState } from "./auth_state.ts";
import {
assertBundleIsSecretFree,
assertReviewBundleIsSecretFree,
bounded,
ensurePrivateDirectory,
makePrivate,
redactText,
safeUrl,
sha256File,
workdirLogicalPath,
} from "./artifacts.ts";
import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
validateBaseUrl,
} from "./scenario.ts";
import type {
CaptureError,
CaptureEvidence,
CapturePoint,
DiagnosticSummary,
Interaction,
InteractionEvidence,
Persona,
ReadyCondition,
ReviewContext,
RouteScenario,
Scenario,
ScreenshotEvidence,
Viewport,
} from "./types.ts";
export type CaptureOptions = {
scenarioPath: string;
outputDirectory: string;
baseUrl?: string;
runId?: string;
personas?: string[];
routes?: string[];
viewports?: string[];
headed?: boolean;
};
type SourceState = { revision: string | null; dirty: boolean | null };
type ErrorCollector = { errors: CaptureError[]; observed: number; limit: number };
const CAPTURE_ERROR_LIMIT = 100;
function recordError(collector: ErrorCollector, error: CaptureError): void {
collector.observed++;
if (collector.errors.length < collector.limit) collector.errors.push(error);
}
function errorSummary(collector: ErrorCollector): DiagnosticSummary {
return {
observed: collector.observed,
retained: collector.errors.length,
truncated: collector.observed > collector.errors.length,
limit: collector.limit,
};
}
function interactionEvidence(interaction: Interaction): InteractionEvidence {
if (interaction.action === "wait") return { action: "wait", ready: interaction.ready };
if (interaction.action === "click") return { action: "click", selector: interaction.selector };
if (interaction.action === "fill") {
return { action: "fill", selector: interaction.selector, value: "[REDACTED]" };
}
return { action: "press", selector: interaction.selector, key: interaction.key };
}
function slug(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase();
}
function viewportId(viewport: Viewport): string {
return viewport.label ?? `${viewport.width}x${viewport.height}`;
}
function timestampId(): string {
return new Date().toISOString().replaceAll(/[:.]/g, "-");
}
async function sourceState(): Promise<SourceState> {
try {
const [revision, status] = await Promise.all([
new Deno.Command("git", { args: ["rev-parse", "HEAD"], stdout: "piped", stderr: "null" })
.output(),
new Deno.Command("git", { args: ["status", "--porcelain"], stdout: "piped", stderr: "null" })
.output(),
]);
return {
revision: revision.success ? new TextDecoder().decode(revision.stdout).trim() : null,
dirty: status.success ? new TextDecoder().decode(status.stdout).trim().length > 0 : null,
};
} catch {
return { revision: null, dirty: null };
}
}
async function repositoryRoot(): Promise<string> {
try {
const result = await new Deno.Command("git", {
args: ["rev-parse", "--show-toplevel"],
stdout: "piped",
stderr: "null",
}).output();
if (result.success) return resolve(new TextDecoder().decode(result.stdout).trim());
} catch {
// Fall back to the invocation directory outside a Git checkout.
}
return resolve(Deno.cwd());
}
function selectById<T extends { id: string }>(
values: T[],
requested: string[] | undefined,
kind: string,
): T[] {
if (!requested || requested.length === 0) return values;
const requestedSet = new Set(requested);
const selected = values.filter((value) => requestedSet.has(value.id));
const missing = [...requestedSet].filter((id) => !selected.some((value) => value.id === id));
if (missing.length > 0) throw new Error(`unknown ${kind}: ${missing.join(", ")}`);
return selected;
}
function selectViewports(values: Viewport[], requested: string[] | undefined): Viewport[] {
if (!requested || requested.length === 0) return values;
const requestedSet = new Set(requested);
const selected = values.filter((value) => requestedSet.has(viewportId(value)));
const missing = [...requestedSet].filter((id) =>
!selected.some((value) => viewportId(value) === id)
);
if (missing.length > 0) throw new Error(`unknown viewports: ${missing.join(", ")}`);
return selected;
}
function responseMatches(
response: Response,
ready: Extract<ReadyCondition, { kind: "response" }>,
): boolean {
const pattern = new RegExp(ready.urlPattern);
return pattern.test(response.url()) &&
(ready.status === undefined || response.status() === ready.status);
}
async function waitReady(
page: Page,
ready: ReadyCondition,
navigation?: Response | null,
): Promise<void> {
const timeout = ready.timeoutMs ?? 15_000;
if (ready.kind === "selector") {
await page.locator(ready.selector).first().waitFor({ state: "visible", timeout });
return;
}
if (ready.kind === "network-idle") {
await page.waitForLoadState("networkidle", { timeout });
return;
}
if (navigation && responseMatches(navigation, ready)) return;
await page.waitForResponse((response) => responseMatches(response, ready), { timeout });
}
async function performInteraction(page: Page, interaction: Interaction): Promise<void> {
if (interaction.action === "wait") return await waitReady(page, interaction.ready);
const locator = page.locator(interaction.selector).first();
const timeout = interaction.timeoutMs ?? 10_000;
if (interaction.action === "click") return await locator.click({ timeout });
if (interaction.action === "fill") {
return await locator.fill(interpolateEnvironment(interaction.value), { timeout });
}
await locator.press(interaction.key, { timeout });
}
async function retry<T>(label: string, operation: () => Promise<T>): Promise<T> {
let last: unknown;
for (let attempt = 1; attempt <= 2; attempt++) {
try {
return await operation();
} catch (error) {
last = error;
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 350));
}
}
throw new Error(
`${label} failed after 2 attempts: ${last instanceof Error ? last.message : String(last)}`,
);
}
async function hideRedactedSelectors(page: Page, selectors: string[]): Promise<void> {
if (selectors.length === 0) return;
const escaped = selectors.join(",\n");
await page.addStyleTag({ content: `${escaped} { visibility: hidden !important; }` });
}
export function isVisibleUiErrorText(content: string): boolean {
return /\b(error|failed|unauthorized|forbidden|not found)\b/i.test(content);
}
async function collectVisibleUiErrors(
page: Page,
collector: ErrorCollector,
secrets: string[],
): Promise<void> {
const alerts = page.locator('[role="alert"], [aria-live="assertive"]');
for (let index = 0; index < await alerts.count(); index++) {
const alert = alerts.nth(index);
if (!await alert.isVisible().catch(() => false)) continue;
const content = (await alert.innerText().catch(() => "")).trim();
if (!isVisibleUiErrorText(content)) continue;
const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`;
if (
!collector.errors.some((error) => error.kind === "document" && error.message === message)
) {
recordError(collector, { kind: "document", message });
}
}
}
async function capturePoint(
page: Page,
runDirectory: string,
repositoryRoot: string,
persona: Persona,
route: RouteScenario,
viewport: Viewport,
point: CapturePoint,
documentResponse: Response | null,
collector: ErrorCollector,
executedInteractions: InteractionEvidence[],
scenario: Scenario,
): Promise<CaptureEvidence> {
const startedAt = new Date().toISOString();
for (const interaction of point.interaction ?? []) {
await performInteraction(page, interaction);
executedInteractions.push(interactionEvidence(interaction));
}
if (point.ready) await waitReady(page, point.ready);
await hideRedactedSelectors(page, scenario.redact?.selectors ?? []);
await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []);
const directory = join(
runDirectory,
"captures",
persona.id,
route.id,
viewportId(viewport),
point.id,
);
await ensurePrivateDirectory(directory);
const viewportScreenshot = join(directory, "viewport.png");
await page.screenshot({ path: viewportScreenshot, fullPage: false, animations: "disabled" });
await makePrivate(viewportScreenshot);
const screenshots: ScreenshotEvidence[] = [{
kind: "viewport",
bundlePath: relative(runDirectory, viewportScreenshot),
workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot),
sha256: await sha256File(viewportScreenshot),
}];
if (point.fullPage) {
const fullPageScreenshot = join(directory, "full-page.png");
await page.screenshot({ path: fullPageScreenshot, fullPage: true, animations: "disabled" });
await makePrivate(fullPageScreenshot);
screenshots.push({
kind: "full-page",
bundlePath: relative(runDirectory, fullPageScreenshot),
workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot),
sha256: await sha256File(fullPageScreenshot),
});
}
let snapshot: { bundlePath: string; workdirPath: string | null } | null = null;
try {
const accessibility = await page.locator("body").ariaSnapshot({ timeout: 5_000 });
const redacted = redactText(accessibility, scenario.redact?.text ?? []);
const target = join(directory, "accessibility.md");
await Deno.writeTextFile(target, redacted, { mode: 0o600 });
snapshot = {
bundlePath: relative(runDirectory, target),
workdirPath: workdirLogicalPath(repositoryRoot, target),
};
} catch (error) {
recordError(collector, {
kind: "tool",
message: `accessibility snapshot failed: ${
bounded(error instanceof Error ? error.message : String(error))
}`,
});
}
return {
persona: { id: persona.id, label: persona.label },
route: {
id: route.id,
path: route.path,
goal: route.goal,
dataState: route.dataState,
ready: route.ready,
},
viewport,
theme: scenario.colorScheme ?? "light",
capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null },
interactions: [...executedInteractions],
document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null },
screenshots,
snapshot,
errors: [...collector.errors],
errorSummary: errorSummary(collector),
startedAt,
finishedAt: new Date().toISOString(),
};
}
function screenshotDataUrl(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:image/png;base64,${btoa(binary)}`;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll(
'"',
"&quot;",
);
}
async function createContactSheet(
browser: Browser,
runDirectory: string,
repositoryRoot: string,
captures: CaptureEvidence[],
): Promise<{
html: { bundlePath: string; workdirPath: string | null } | null;
png: { bundlePath: string; workdirPath: string | null } | null;
}> {
const cells: string[] = [];
for (const capture of captures) {
const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ??
capture.screenshots[0];
if (!screenshot) continue;
const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath));
cells.push(
`<figure><img src="${screenshotDataUrl(bytes)}"><figcaption><strong>${
escapeHtml(capture.persona.label)
} · ${escapeHtml(capture.route.id)}</strong><br>${
escapeHtml(viewportId(capture.viewport))
} · ${escapeHtml(capture.capturePoint.label)}<br><small>${
escapeHtml(capture.route.dataState)
}</small>${
capture.errors.length > 0
? `<br><strong class="errors">${capture.errors.length} captured error(s)</strong>`
: ""
}</figcaption></figure>`,
);
}
if (cells.length === 0) return { html: null, png: null };
const html =
`<!doctype html><meta charset="utf-8"><title>Web UX review contact sheet</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}main{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;background:white;border:1px solid #aaa;padding:10px;box-shadow:0 2px 8px #0002}img{width:100%;height:auto;display:block;border:1px solid #ddd}figcaption{padding-top:8px;line-height:1.45}small{color:#555}.errors{color:#b42318}</style><main>${
cells.join("")
}</main>`;
const htmlPath = join(runDirectory, "contact-sheet.html");
const pngPath = join(runDirectory, "contact-sheet.png");
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
try {
await page.setContent(html, { waitUntil: "load" });
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
await makePrivate(pngPath);
} finally {
await page.close();
}
return {
html: {
bundlePath: relative(runDirectory, htmlPath),
workdirPath: workdirLogicalPath(repositoryRoot, htmlPath),
},
png: {
bundlePath: relative(runDirectory, pngPath),
workdirPath: workdirLogicalPath(repositoryRoot, pngPath),
},
};
}
export async function capture(options: CaptureOptions): Promise<ReviewContext> {
const scenarioPath = resolve(options.scenarioPath);
const repository = await repositoryRoot();
const scenario = await loadScenario(scenarioPath);
const baseUrl = validateBaseUrl(
interpolateEnvironment(
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
),
);
const personas = selectById(scenario.personas, options.personas, "personas");
const routes = selectById(scenario.routes, options.routes, "routes");
const viewports = selectViewports(scenario.viewports, options.viewports);
const runId = slug(options.runId ?? `${scenario.id}-${timestampId()}`);
const runDirectory = resolve(options.outputDirectory, runId);
try {
await Deno.stat(runDirectory);
throw new Error(`run directory already exists: ${runDirectory}`);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await ensurePrivateDirectory(runDirectory);
const secrets = scenario.redact?.text ?? [];
let browser: Browser | null = null;
let processes: RunningProcess[] = [];
const captures: CaptureEvidence[] = [];
const globalCollector: ErrorCollector = {
errors: [],
observed: 0,
limit: CAPTURE_ERROR_LIMIT,
};
const diagnostics = globalCollector.errors;
let contactSheet: ReviewContext["contactSheet"] = { html: null, png: null };
let browserVersion = "unknown";
let status: ReviewContext["status"] = "completed";
try {
processes = await startOwnedProcesses(
scenario.processes ?? [],
scenarioPath,
join(runDirectory, "process-logs"),
secrets,
);
browser = await chromium.launch({ headless: !options.headed });
browserVersion = browser.version();
for (const persona of personas) {
const storageState = persona.auth.kind === "storage-state"
? resolveScenarioPath(scenarioPath, persona.auth.path)
: undefined;
if (storageState) await validateAuthState(storageState, persona.id, baseUrl);
for (const viewport of viewports) {
const context = await browser.newContext({
storageState,
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
locale: scenario.locale,
timezoneId: scenario.timezone,
colorScheme: scenario.colorScheme,
reducedMotion: scenario.reducedMotion,
});
try {
for (const route of routes) {
const routeCollector: ErrorCollector = {
errors: [],
observed: 0,
limit: CAPTURE_ERROR_LIMIT,
};
const routeErrors = routeCollector.errors;
const executedInteractions: InteractionEvidence[] = [];
const page = await context.newPage();
page.on("console", (message) => {
if (message.type() === "error") {
recordError(routeCollector, {
kind: "console",
message: bounded(redactText(message.text(), secrets)),
});
}
});
page.on(
"pageerror",
(error) =>
recordError(routeCollector, {
kind: "page",
message: bounded(redactText(error.message, secrets)),
}),
);
page.on(
"requestfailed",
(request) =>
recordError(routeCollector, {
kind: "request",
message: bounded(
redactText(request.failure()?.errorText ?? "request failed", secrets),
),
url: safeUrl(request.url()),
}),
);
page.on("response", (response) => {
if (response.status() >= 400) {
recordError(routeCollector, {
kind: "request",
message: `HTTP ${response.status()}`,
url: safeUrl(response.url()),
status: response.status(),
});
}
});
try {
const routePath = interpolateEnvironment(route.path);
const targetUrl = new URL(routePath, `${baseUrl}/`).toString();
const response = await retry(`navigate ${route.id}`, async () => {
const ready = route.ready;
const responseReady = ready.kind === "response"
? page.waitForResponse(
(candidate) => responseMatches(candidate, ready),
{ timeout: ready.timeoutMs ?? 15_000 },
)
: null;
try {
const navigation = await page.goto(targetUrl, {
waitUntil: "domcontentloaded",
timeout: 20_000,
});
if (responseReady) await responseReady;
else await waitReady(page, ready, navigation);
return navigation;
} catch (error) {
responseReady?.catch(() => undefined);
throw error;
}
});
if (response && response.status() >= 400) {
recordError(routeCollector, {
kind: "document",
message: `document returned HTTP ${response.status()}`,
url: safeUrl(response.url()),
status: response.status(),
});
}
for (const point of route.capturePoints) {
captures.push(
await capturePoint(
page,
runDirectory,
repository,
persona,
route,
viewport,
point,
response,
routeCollector,
executedInteractions,
scenario,
),
);
}
} catch (error) {
status = "completed-with-errors";
recordError(routeCollector, {
kind: "tool",
message: bounded(
redactText(error instanceof Error ? error.message : String(error), secrets),
),
});
captures.push({
persona: { id: persona.id, label: persona.label },
route: {
id: route.id,
path: route.path,
goal: route.goal,
dataState: route.dataState,
ready: route.ready,
},
viewport,
theme: scenario.colorScheme ?? "light",
capturePoint: { id: "failed", label: "Capture failed", ready: null },
interactions: [...executedInteractions],
document: { url: safeUrl(page.url()), status: null },
screenshots: [],
snapshot: null,
errors: [...routeErrors],
errorSummary: errorSummary(routeCollector),
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
});
} finally {
await page.close();
}
}
} finally {
await context.close();
}
}
}
contactSheet = await createContactSheet(browser, runDirectory, repository, captures);
if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors";
} catch (error) {
status = "failed";
recordError(globalCollector, {
kind: "tool",
message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)),
});
} finally {
if (browser) {
await browser.close().catch((error) =>
recordError(globalCollector, {
kind: "tool",
message: `browser cleanup failed: ${bounded(String(error))}`,
})
);
}
for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error);
}
if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors";
const manifest: ReviewContext = {
schemaVersion: 1,
runId,
scenario: {
id: scenario.id,
title: scenario.title,
sourcePath: workdirLogicalPath(repository, scenarioPath),
},
source: await sourceState(),
baseUrl: safeUrl(baseUrl),
browser: { name: "chromium", version: browserVersion },
createdAt: new Date().toISOString(),
status,
filters: {
personas: personas.map((item) => item.id),
routes: routes.map((item) => item.id),
viewports: viewports.map(viewportId),
},
captures,
contactSheet,
diagnostics,
diagnosticSummary: errorSummary(globalCollector),
};
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
assertBundleIsSecretFree(serialized, secrets);
await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 });
await assertReviewBundleIsSecretFree(runDirectory, secrets);
if (status === "failed") {
throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`);
}
return manifest;
}
export function describeCapture(manifest: ReviewContext, outputDirectory: string): string {
return `${manifest.status}: ${manifest.captures.length} capture(s); ${
join(outputDirectory, manifest.runId, "review-context.json")
}`;
}
+173
View File
@@ -0,0 +1,173 @@
import { Buffer } from "node:buffer";
import { basename, dirname, join, relative, resolve } from "@std/path";
import { chromium } from "playwright";
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
import { ensurePrivateDirectory, makePrivate, sha256File } from "./artifacts.ts";
import type { CaptureEvidence, ReviewContext } from "./types.ts";
export type CompareOptions = {
before: string;
after: string;
outputDirectory: string;
threshold?: number;
};
type Pair = {
key: string;
before: CaptureEvidence;
after: CaptureEvidence;
beforePath: string;
afterPath: string;
diffPath: string;
changedPixels: number;
totalPixels: number;
dimensionMismatch: boolean;
};
function key(capture: CaptureEvidence): string {
const viewport = capture.viewport.label ?? `${capture.viewport.width}x${capture.viewport.height}`;
return [capture.persona.id, capture.route.id, viewport, capture.capturePoint.id].join("/");
}
async function readManifest(path: string): Promise<ReviewContext> {
const parsed = JSON.parse(await Deno.readTextFile(path));
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.captures)) {
throw new Error(`not a web-ux review context: ${path}`);
}
return parsed;
}
function viewportScreenshot(capture: CaptureEvidence): string | null {
return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ??
capture.screenshots[0]?.bundlePath ?? null;
}
function dataUrl(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:image/png;base64,${btoa(binary)}`;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll(
'"',
"&quot;",
);
}
export async function compare(options: CompareOptions): Promise<string> {
const beforeManifestPath = resolve(options.before);
const afterManifestPath = resolve(options.after);
const [before, after] = await Promise.all([
readManifest(beforeManifestPath),
readManifest(afterManifestPath),
]);
const outputDirectory = resolve(options.outputDirectory);
await ensurePrivateDirectory(outputDirectory);
const afterByKey = new Map(after.captures.map((capture) => [key(capture), capture]));
const pairs: Pair[] = [];
const unmatchedBefore: string[] = [];
for (const earlier of before.captures) {
const later = afterByKey.get(key(earlier));
const earlierScreenshot = viewportScreenshot(earlier);
const laterScreenshot = later ? viewportScreenshot(later) : null;
if (!later || !earlierScreenshot || !laterScreenshot) {
unmatchedBefore.push(key(earlier));
continue;
}
afterByKey.delete(key(earlier));
const beforePath = resolve(dirname(beforeManifestPath), earlierScreenshot);
const afterPath = resolve(dirname(afterManifestPath), laterScreenshot);
const [beforeImage, afterImage] = [
PNG.sync.read(Buffer.from(Deno.readFileSync(beforePath))),
PNG.sync.read(Buffer.from(Deno.readFileSync(afterPath))),
];
const dimensionMismatch = beforeImage.width !== afterImage.width ||
beforeImage.height !== afterImage.height;
const width = Math.max(beforeImage.width, afterImage.width);
const height = Math.max(beforeImage.height, afterImage.height);
const diff = new PNG({ width, height, fill: true });
let changedPixels = width * height;
if (!dimensionMismatch) {
changedPixels = pixelmatch(beforeImage.data, afterImage.data, diff.data, width, height, {
threshold: options.threshold ?? 0.1,
includeAA: false,
});
}
const diffPath = join(outputDirectory, "diffs", `${key(earlier).replaceAll("/", "--")}.png`);
await ensurePrivateDirectory(dirname(diffPath));
await Deno.writeFile(diffPath, PNG.sync.write(diff), { mode: 0o600 });
await makePrivate(diffPath);
pairs.push({
key: key(earlier),
before: earlier,
after: later,
beforePath,
afterPath,
diffPath,
changedPixels,
totalPixels: width * height,
dimensionMismatch,
});
}
const cells: string[] = [];
for (const pair of pairs) {
const [beforeBytes, afterBytes, diffBytes] = await Promise.all([
Deno.readFile(pair.beforePath),
Deno.readFile(pair.afterPath),
Deno.readFile(pair.diffPath),
]);
cells.push(
`<section><h2>${escapeHtml(pair.key)}</h2><p>${
pair.dimensionMismatch
? "dimension mismatch"
: `${pair.changedPixels} / ${pair.totalPixels} pixels changed`
}</p><div class="row"><figure><img src="${
dataUrl(beforeBytes)
}"><figcaption>before</figcaption></figure><figure><img src="${
dataUrl(afterBytes)
}"><figcaption>after</figcaption></figure><figure><img src="${
dataUrl(diffBytes)
}"><figcaption>diff</figcaption></figure></div></section>`,
);
}
const html =
`<!doctype html><meta charset="utf-8"><title>Web UX comparison</title><style>body{margin:0;padding:20px;background:#e8e8e8;color:#111;font:14px system-ui}section{background:white;border:1px solid #aaa;margin:0 0 24px;padding:12px}.row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}figure{margin:0}img{width:100%;height:auto;border:1px solid #ddd}figcaption{text-align:center;padding:6px}h2{font-size:16px;margin:0}p{color:#555}</style>${
cells.join("")
}`;
const htmlPath = join(outputDirectory, "comparison.html");
const pngPath = join(outputDirectory, "comparison.png");
await Deno.writeTextFile(htmlPath, html, { mode: 0o600 });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
await page.setContent(html, { waitUntil: "load" });
await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" });
await makePrivate(pngPath);
} finally {
await browser.close();
}
const report = {
schemaVersion: 1,
before: beforeManifestPath,
after: afterManifestPath,
createdAt: new Date().toISOString(),
threshold: options.threshold ?? 0.1,
pairs: await Promise.all(pairs.map(async (pair) => ({
key: pair.key,
changedPixels: pair.changedPixels,
totalPixels: pair.totalPixels,
changedRatio: pair.totalPixels === 0 ? 0 : pair.changedPixels / pair.totalPixels,
dimensionMismatch: pair.dimensionMismatch,
diff: relative(outputDirectory, pair.diffPath),
diffSha256: await sha256File(pair.diffPath),
}))),
unmatchedBefore,
unmatchedAfter: [...afterByKey.keys()],
contactSheet: { html: basename(htmlPath), png: basename(pngPath) },
};
const reportPath = join(outputDirectory, "comparison.json");
await Deno.writeTextFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
return reportPath;
}
+124
View File
@@ -0,0 +1,124 @@
import { dirname, resolve } from "@std/path";
import { chromium } from "playwright";
import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts";
import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
validateBaseUrl,
} from "./scenario.ts";
export type AuthOptions = {
scenarioPath: string;
personaId: string;
baseUrl?: string;
importState?: string;
timeoutMs?: number;
expiresInHours?: number;
delete?: boolean;
headless?: boolean;
};
function validateStorageState(value: unknown): { cookies: unknown[]; origins: unknown[] } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("storage state must be an object");
}
const source = value as Record<string, unknown>;
if (!Array.isArray(source.cookies) || !Array.isArray(source.origins)) {
throw new Error("storage state must contain cookies and origins arrays");
}
return { cookies: source.cookies, origins: source.origins };
}
export async function authenticate(options: AuthOptions): Promise<string> {
const scenarioPath = resolve(options.scenarioPath);
const scenario = await loadScenario(scenarioPath);
const persona = scenario.personas.find((candidate) => candidate.id === options.personaId);
if (!persona) throw new Error(`unknown persona: ${options.personaId}`);
if (persona.auth.kind !== "storage-state") {
throw new Error(`persona ${persona.id} is anonymous and has no auth state`);
}
const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path);
if (options.delete) {
await deleteAuthState(outputPath);
return outputPath;
}
await ensurePrivateDirectory(dirname(outputPath));
const baseUrl = validateBaseUrl(
interpolateEnvironment(
options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "",
),
);
const expiresInHours = options.expiresInHours ?? 12;
if (options.importState) {
const imported = validateStorageState(
JSON.parse(await Deno.readTextFile(resolve(options.importState))),
);
await writePrivateJson(outputPath, imported);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
}
if (!persona.login) {
throw new Error(`persona ${persona.id} needs login configuration or --import-state`);
}
const browser = await chromium.launch({ headless: options.headless ?? false });
try {
const context = await browser.newContext();
const page = await context.newPage();
const loginUrl = new URL(interpolateEnvironment(persona.login.path ?? "/"), `${baseUrl}/`)
.toString();
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
const success = new RegExp(interpolateEnvironment(persona.login.successUrl));
if (!success.test(page.url())) {
await page.waitForURL((url) => success.test(url.toString()), {
timeout: options.timeoutMs ?? 300_000,
});
}
await context.storageState({ path: outputPath });
await makePrivate(outputPath);
await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours);
return outputPath;
} finally {
await browser.close();
}
}
export type CleanupOptions = {
outputDirectory: string;
keep: number;
olderThanDays?: number;
dryRun?: boolean;
};
export async function cleanup(options: CleanupOptions): Promise<string[]> {
const outputDirectory = resolve(options.outputDirectory);
const candidates: { path: string; modified: number }[] = [];
try {
for await (const entry of Deno.readDir(outputDirectory)) {
if (!entry.isDirectory) continue;
const path = resolve(outputDirectory, entry.name);
try {
await Deno.stat(resolve(path, "review-context.json"));
const stat = await Deno.stat(path);
candidates.push({ path, modified: stat.mtime?.getTime() ?? 0 });
} catch {
// Only complete review bundle directories are owned by cleanup.
}
}
} catch (error) {
if (error instanceof Deno.errors.NotFound) return [];
throw error;
}
candidates.sort((left, right) => right.modified - left.modified);
const cutoff = options.olderThanDays === undefined
? Number.POSITIVE_INFINITY
: Date.now() - options.olderThanDays * 24 * 60 * 60 * 1000;
const removed: string[] = [];
for (const [index, candidate] of candidates.entries()) {
if (index < options.keep || candidate.modified > cutoff) continue;
removed.push(candidate.path);
if (!options.dryRun) await Deno.remove(candidate.path, { recursive: true });
}
return removed;
}
+254
View File
@@ -0,0 +1,254 @@
import { dirname, isAbsolute, resolve } from "@std/path";
import { bounded, redactText, writePrivateJson } from "./artifacts.ts";
import type { CaptureError, OwnedProcess } from "./types.ts";
export const PROCESS_LOG_BYTE_LIMIT = 1024 * 1024;
const PROCESS_STOP_TIMEOUT_MS = 3_000;
export type RunningProcess = {
id: string;
pid: number;
child: Deno.ChildProcess;
status: Promise<Deno.CommandStatus>;
output: Promise<void>;
};
async function appendOutput(
stream: ReadableStream<Uint8Array>,
destination: string,
secrets: string[],
): Promise<void> {
const file = await Deno.open(destination, {
create: true,
append: true,
write: true,
mode: 0o600,
});
const encoder = new TextEncoder();
const overlapCharacters = Math.max(512, ...secrets.map((secret) => secret.length + 128));
let pending = "";
let bytesObserved = 0;
let bytesWritten = 0;
let truncated = false;
const writeRedacted = async (value: string) => {
const encoded = encoder.encode(redactText(value, secrets));
const remaining = Math.max(0, PROCESS_LOG_BYTE_LIMIT - bytesWritten);
if (encoded.length > remaining) truncated = true;
if (remaining > 0) {
const output = encoded.subarray(0, remaining);
await file.write(output);
bytesWritten += output.length;
}
};
try {
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
bytesObserved += encoder.encode(value).length;
pending += value;
if (pending.length > overlapCharacters * 2) {
const splitAt = pending.length - overlapCharacters;
await writeRedacted(pending.slice(0, splitAt));
pending = pending.slice(splitAt);
}
}
await writeRedacted(pending);
} finally {
file.close();
await writePrivateJson(`${destination}.meta.json`, {
schemaVersion: 1,
byteLimit: PROCESS_LOG_BYTE_LIMIT,
bytesObserved,
bytesWritten,
truncated,
});
}
}
async function waitForReady(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError = "not attempted";
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) });
const status = response.status;
await response.body?.cancel();
if (status < 500) return;
lastError = `HTTP ${status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`process readiness timed out for ${url}: ${bounded(lastError, 200)}`);
}
export async function startOwnedProcesses(
specifications: OwnedProcess[],
scenarioPath: string,
logsDirectory: string,
secrets: string[],
): Promise<RunningProcess[]> {
const running: RunningProcess[] = [];
await Deno.mkdir(logsDirectory, { recursive: true });
try {
for (const specification of specifications) {
const cwd = specification.cwd === undefined
? dirname(resolve(scenarioPath))
: isAbsolute(specification.cwd)
? specification.cwd
: resolve(dirname(scenarioPath), specification.cwd);
const child = new Deno.Command(specification.command, {
args: specification.args ?? [],
cwd,
env: specification.env,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdout = appendOutput(
child.stdout,
`${logsDirectory}/${specification.id}.stdout.log`,
secrets,
);
const stderr = appendOutput(
child.stderr,
`${logsDirectory}/${specification.id}.stderr.log`,
secrets,
);
const status = child.status;
const process = {
id: specification.id,
pid: child.pid,
child,
status,
output: Promise.all([stdout, stderr]).then(() => undefined),
};
running.push(process);
if (specification.readyUrl) {
await Promise.race([
waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000),
status.then((status) => {
throw new Error(
`owned process ${specification.id} exited before readiness: ${status.code}`,
);
}),
]);
}
}
return running;
} catch (error) {
await stopOwnedProcesses(running);
throw error;
}
}
async function descendantPids(parentPid: number): Promise<number[]> {
if (Deno.build.os === "windows") return [];
try {
const result = await new Deno.Command("ps", {
args: ["-eo", "pid=,ppid="],
stdout: "piped",
stderr: "null",
}).output();
if (!result.success) return [];
const rows = new TextDecoder().decode(result.stdout).trim().split("\n").map((line) =>
line.trim().split(/\s+/).map(Number)
);
const descendants: number[] = [];
const queue = [parentPid];
while (queue.length > 0) {
const parent = queue.shift()!;
for (const [pid, ppid] of rows) {
if (ppid === parent && !descendants.includes(pid)) {
descendants.push(pid);
queue.push(pid);
}
}
}
return descendants.reverse();
} catch {
return [];
}
}
function tryKill(pid: number, signal: Deno.Signal): void {
try {
Deno.kill(pid, signal);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
async function livePids(pids: number[]): Promise<number[]> {
if (Deno.build.os === "windows") return [];
if (pids.length === 0) return [];
try {
const result = await new Deno.Command("ps", {
args: ["-o", "pid=", "-p", pids.join(",")],
stdout: "piped",
stderr: "null",
}).output();
if (!result.success && result.code !== 1) return pids;
const live = new Set(
new TextDecoder().decode(result.stdout).trim().split(/\s+/).map(Number).filter(
Number.isFinite,
),
);
return pids.filter((pid) => live.has(pid));
} catch {
return pids;
}
}
async function waitForPidsToExit(pids: number[], timeoutMs: number): Promise<number[]> {
const deadline = Date.now() + timeoutMs;
let live = await livePids(pids);
while (live.length > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
live = await livePids(live);
}
return live;
}
export async function stopOwnedProcesses(processes: RunningProcess[]): Promise<CaptureError[]> {
const diagnostics: CaptureError[] = [];
for (const process of [...processes].reverse()) {
try {
const descendants = await descendantPids(process.pid);
tryKill(process.pid, "SIGTERM");
for (const pid of descendants) tryKill(pid, "SIGTERM");
let timer: number | undefined;
const [parentExited, liveDescendants] = await Promise.all([
Promise.race([
process.status.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timer)),
waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS),
]);
if (!parentExited || liveDescendants.length > 0) {
const lateDescendants = await descendantPids(process.pid);
const forceTargets = [...new Set([...liveDescendants, ...lateDescendants])];
for (const pid of forceTargets) tryKill(pid, "SIGKILL");
tryKill(process.pid, "SIGKILL");
await process.status;
const survivors = await waitForPidsToExit(forceTargets, 1_000);
if (survivors.length > 0) {
throw new Error(`descendant processes did not exit: ${survivors.join(",")}`);
}
}
await process.output;
} catch (error) {
diagnostics.push({
kind: "tool",
message: `failed to clean process ${process.id}: ${
bounded(error instanceof Error ? error.message : String(error), 500)
}`,
});
}
}
return diagnostics;
}
+285
View File
@@ -0,0 +1,285 @@
import { isAbsolute, join, resolve } from "@std/path";
import type {
CapturePoint,
Persona,
ReadyCondition,
RouteScenario,
Scenario,
Viewport,
} from "./types.ts";
function record(value: unknown, at: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${at} must be an object`);
}
return value as Record<string, unknown>;
}
function text(value: unknown, at: string): string {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${at} must be text`);
return value;
}
function positiveInteger(value: unknown, at: string): number {
if (!Number.isInteger(value) || (value as number) <= 0) {
throw new Error(`${at} must be a positive integer`);
}
return value as number;
}
function identifier(value: unknown, at: string): string {
const result = text(value, at);
if (!/^[a-z0-9][a-z0-9-]*$/.test(result)) {
throw new Error(`${at} must contain lowercase ASCII letters, digits, or hyphens`);
}
return result;
}
function stringArray(value: unknown, at: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error(`${at} must be an array`);
return value.map((item, index) => text(item, `${at}[${index}]`));
}
function parseReady(value: unknown, at: string): ReadyCondition {
const source = record(value, at);
const kind = text(source.kind, `${at}.kind`);
const timeoutMs = source.timeoutMs === undefined
? undefined
: positiveInteger(source.timeoutMs, `${at}.timeoutMs`);
if (kind === "selector") {
return { kind, selector: text(source.selector, `${at}.selector`), timeoutMs };
}
if (kind === "response") {
const status = source.status === undefined
? undefined
: positiveInteger(source.status, `${at}.status`);
return { kind, urlPattern: text(source.urlPattern, `${at}.urlPattern`), status, timeoutMs };
}
if (kind === "network-idle") return { kind, timeoutMs };
throw new Error(`${at}.kind is unsupported: ${kind}`);
}
function parseCapturePoint(value: unknown, at: string): CapturePoint {
const source = record(value, at);
const result: CapturePoint = {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
fullPage: source.fullPage === undefined ? false : Boolean(source.fullPage),
};
if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`);
if (source.interaction !== undefined) {
if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`);
if (source.interaction.length > 20) {
throw new Error(`${at}.interaction must not exceed 20 items`);
}
result.interaction = source.interaction.map((raw, index) => {
const action = record(raw, `${at}.interaction[${index}]`);
const name = text(action.action, `${at}.interaction[${index}].action`);
if (name === "wait") {
return {
action: name,
ready: parseReady(action.ready, `${at}.interaction[${index}].ready`),
};
}
const selector = text(action.selector, `${at}.interaction[${index}].selector`);
const timeoutMs = action.timeoutMs === undefined
? undefined
: positiveInteger(action.timeoutMs, `${at}.interaction[${index}].timeoutMs`);
if (name === "click") return { action: name, selector, timeoutMs };
if (name === "fill") {
return {
action: name,
selector,
value: text(action.value, `${at}.interaction[${index}].value`),
timeoutMs,
};
}
if (name === "press") {
return {
action: name,
selector,
key: text(action.key, `${at}.interaction[${index}].key`),
timeoutMs,
};
}
throw new Error(`${at}.interaction[${index}].action is unsupported: ${name}`);
});
}
return result;
}
function parsePersona(value: unknown, at: string): Persona {
const source = record(value, at);
const auth = record(source.auth, `${at}.auth`);
const kind = text(auth.kind, `${at}.auth.kind`);
const persona: Persona = {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
auth: kind === "anonymous"
? { kind }
: kind === "storage-state"
? { kind, path: text(auth.path, `${at}.auth.path`) }
: (() => {
throw new Error(`${at}.auth.kind is unsupported: ${kind}`);
})(),
};
if (source.login !== undefined) {
const login = record(source.login, `${at}.login`);
persona.login = {
path: login.path === undefined ? "/" : text(login.path, `${at}.login.path`),
successUrl: text(login.successUrl, `${at}.login.successUrl`),
};
}
return persona;
}
function parseViewport(value: unknown, at: string): Viewport {
const source = record(value, at);
return {
width: positiveInteger(source.width, `${at}.width`),
height: positiveInteger(source.height, `${at}.height`),
label: source.label === undefined ? undefined : identifier(source.label, `${at}.label`),
deviceScaleFactor: source.deviceScaleFactor === undefined
? 1
: positiveInteger(source.deviceScaleFactor, `${at}.deviceScaleFactor`),
};
}
function parseRoute(value: unknown, at: string): RouteScenario {
const source = record(value, at);
if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) {
throw new Error(`${at}.capturePoints must have at least one item`);
}
if (source.capturePoints.length > 12) {
throw new Error(`${at}.capturePoints must not exceed 12 items`);
}
return {
id: identifier(source.id, `${at}.id`),
label: text(source.label, `${at}.label`),
path: text(source.path, `${at}.path`),
goal: text(source.goal, `${at}.goal`),
dataState: text(source.dataState, `${at}.dataState`),
ready: parseReady(source.ready, `${at}.ready`),
capturePoints: source.capturePoints.map((point, index) =>
parseCapturePoint(point, `${at}.capturePoints[${index}]`)
),
};
}
function uniqueIds(values: { id: string }[], at: string): void {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value.id)) throw new Error(`${at} contains duplicate id: ${value.id}`);
seen.add(value.id);
}
}
export function interpolateEnvironment(value: string, environment = Deno.env.toObject()): string {
return value.replaceAll(/\$\{([A-Z][A-Z0-9_]*)\}/g, (_match, name: string) => {
const replacement = environment[name];
if (replacement === undefined) {
throw new Error(`required environment variable is missing: ${name}`);
}
return replacement;
});
}
export function validateBaseUrl(value: string): string {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("base URL must use http or https");
}
if (url.username || url.password) throw new Error("base URL must not contain credentials");
url.pathname = url.pathname.replace(/\/$/, "");
return url.toString().replace(/\/$/, "");
}
export function resolveScenarioPath(sourcePath: string, value: string): string {
const expanded = interpolateEnvironment(value);
return isAbsolute(expanded) ? expanded : resolve(join(sourcePath, "..", expanded));
}
export async function loadScenario(sourcePath: string): Promise<Scenario> {
const parsed = JSON.parse(await Deno.readTextFile(sourcePath));
const source = record(parsed, "scenario");
if (source.schemaVersion !== 1) throw new Error("scenario.schemaVersion must equal 1");
if (!Array.isArray(source.personas) || source.personas.length === 0) {
throw new Error("scenario.personas must have at least one item");
}
if (source.personas.length > 8) throw new Error("scenario.personas must not exceed 8 items");
if (!Array.isArray(source.viewports) || source.viewports.length === 0) {
throw new Error("scenario.viewports must have at least one item");
}
if (source.viewports.length > 8) throw new Error("scenario.viewports must not exceed 8 items");
if (!Array.isArray(source.routes) || source.routes.length === 0) {
throw new Error("scenario.routes must have at least one item");
}
if (source.routes.length > 40) throw new Error("scenario.routes must not exceed 40 items");
const personas = source.personas.map((value, index) =>
parsePersona(value, `scenario.personas[${index}]`)
);
const routes = source.routes.map((value, index) =>
parseRoute(value, `scenario.routes[${index}]`)
);
const scenario: Scenario = {
schemaVersion: 1,
id: identifier(source.id, "scenario.id"),
title: text(source.title, "scenario.title"),
baseUrl: source.baseUrl === undefined ? undefined : text(source.baseUrl, "scenario.baseUrl"),
locale: source.locale === undefined ? "en-US" : text(source.locale, "scenario.locale"),
timezone: source.timezone === undefined ? "UTC" : text(source.timezone, "scenario.timezone"),
colorScheme: source.colorScheme === "dark" ? "dark" : "light",
reducedMotion: source.reducedMotion === "no-preference" ? "no-preference" : "reduce",
redact: source.redact === undefined ? undefined : (() => {
const redact = record(source.redact, "scenario.redact");
return {
selectors: stringArray(redact.selectors, "scenario.redact.selectors"),
text: stringArray(redact.text, "scenario.redact.text").map((value) =>
interpolateEnvironment(value)
),
};
})(),
personas,
viewports: source.viewports.map((value, index) =>
parseViewport(value, `scenario.viewports[${index}]`)
),
routes,
};
if (source.processes !== undefined) {
if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array");
if (source.processes.length > 8) throw new Error("scenario.processes must not exceed 8 items");
scenario.processes = source.processes.map((value, index) => {
const at = `scenario.processes[${index}]`;
const process = record(value, at);
const env = process.env === undefined ? undefined : record(process.env, `${at}.env`);
return {
id: identifier(process.id, `${at}.id`),
command: text(process.command, `${at}.command`),
args: stringArray(process.args, `${at}.args`),
cwd: process.cwd === undefined ? undefined : text(process.cwd, `${at}.cwd`),
env: env === undefined ? undefined : Object.fromEntries(
Object.entries(env).map((
[key, raw],
) => [key, interpolateEnvironment(text(raw, `${at}.env.${key}`))]),
),
readyUrl: process.readyUrl === undefined ? undefined : validateBaseUrl(
interpolateEnvironment(text(process.readyUrl, `${at}.readyUrl`)),
),
readyTimeoutMs: process.readyTimeoutMs === undefined
? undefined
: positiveInteger(process.readyTimeoutMs, `${at}.readyTimeoutMs`),
};
});
uniqueIds(scenario.processes, "scenario.processes");
}
uniqueIds(personas, "scenario.personas");
uniqueIds(routes, "scenario.routes");
for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`);
const captureCount = personas.length * scenario.viewports.length *
routes.reduce((total, route) => total + route.capturePoints.length, 0);
if (captureCount > 200) {
throw new Error(`scenario capture matrix must not exceed 200 items (received ${captureCount})`);
}
return scenario;
}
+149
View File
@@ -0,0 +1,149 @@
export type Viewport = {
width: number;
height: number;
label?: string;
deviceScaleFactor?: number;
};
export type Persona = {
id: string;
label: string;
auth: { kind: "anonymous" } | { kind: "storage-state"; path: string };
login?: {
path?: string;
successUrl: string;
};
};
export type ReadyCondition =
| { kind: "selector"; selector: string; timeoutMs?: number }
| { kind: "response"; urlPattern: string; status?: number; timeoutMs?: number }
| { kind: "network-idle"; timeoutMs?: number };
export type Interaction =
| { action: "click"; selector: string; timeoutMs?: number }
| { action: "fill"; selector: string; value: string; timeoutMs?: number }
| { action: "press"; selector: string; key: string; timeoutMs?: number }
| { action: "wait"; ready: ReadyCondition };
export type CapturePoint = {
id: string;
label: string;
interaction?: Interaction[];
ready?: ReadyCondition;
fullPage?: boolean;
};
export type RouteScenario = {
id: string;
label: string;
path: string;
goal: string;
dataState: string;
ready: ReadyCondition;
capturePoints: CapturePoint[];
};
export type OwnedProcess = {
id: string;
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
readyUrl?: string;
readyTimeoutMs?: number;
};
export type Scenario = {
schemaVersion: 1;
id: string;
title: string;
baseUrl?: string;
locale?: string;
timezone?: string;
colorScheme?: "light" | "dark";
reducedMotion?: "reduce" | "no-preference";
redact?: {
selectors?: string[];
text?: string[];
};
personas: Persona[];
viewports: Viewport[];
routes: RouteScenario[];
processes?: OwnedProcess[];
};
export type CaptureError = {
kind: "console" | "page" | "request" | "document" | "tool";
message: string;
url?: string;
status?: number;
};
export type ArtifactReference = {
bundlePath: string;
workdirPath: string | null;
};
export type ScreenshotEvidence = ArtifactReference & {
kind: "viewport" | "full-page";
sha256: string;
};
export type InteractionEvidence =
| { action: "click"; selector: string }
| { action: "fill"; selector: string; value: "[REDACTED]" }
| { action: "press"; selector: string; key: string }
| { action: "wait"; ready: ReadyCondition };
export type DiagnosticSummary = {
observed: number;
retained: number;
truncated: boolean;
limit: number;
};
export type CaptureEvidence = {
persona: { id: string; label: string };
route: {
id: string;
path: string;
goal: string;
dataState: string;
ready: ReadyCondition;
};
viewport: Viewport;
theme: string;
capturePoint: {
id: string;
label: string;
ready: ReadyCondition | null;
};
interactions: InteractionEvidence[];
document: { url: string; status: number | null };
screenshots: ScreenshotEvidence[];
snapshot: ArtifactReference | null;
errors: CaptureError[];
errorSummary: DiagnosticSummary;
startedAt: string;
finishedAt: string;
};
export type ReviewContext = {
schemaVersion: 1;
runId: string;
scenario: { id: string; title: string; sourcePath: string | null };
source: { revision: string | null; dirty: boolean | null };
baseUrl: string;
browser: { name: "chromium"; version: string };
createdAt: string;
status: "completed" | "completed-with-errors" | "failed";
filters: { personas: string[]; routes: string[]; viewports: string[] };
captures: CaptureEvidence[];
contactSheet: {
html: ArtifactReference | null;
png: ArtifactReference | null;
};
diagnostics: CaptureError[];
diagnosticSummary: DiagnosticSummary;
};
+56
View File
@@ -0,0 +1,56 @@
import { assertRejects } from "@std/assert";
import { join } from "@std/path";
import {
authMetadataPath,
deleteAuthState,
validateAuthState,
writeAuthMetadata,
} from "../src/auth_state.ts";
Deno.test("auth state is bound to persona, base origin, and expiry", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}');
await writeAuthMetadata(state, "owner", "https://example.test/path", 1);
await validateAuthState(state, "owner", "https://example.test/other");
await assertRejects(
() => validateAuthState(state, "non-owner", "https://example.test"),
Error,
"does not match persona",
);
await assertRejects(
() => validateAuthState(state, "owner", "https://other.test"),
Error,
"belongs to https://example.test",
);
await assertRejects(
() =>
validateAuthState(
state,
"owner",
"https://example.test",
new Date(Date.now() + 2 * 60 * 60 * 1000),
),
Error,
"auth state expired",
);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("auth state deletion removes state and metadata idempotently", async () => {
const directory = await Deno.makeTempDir();
try {
const state = join(directory, "owner.json");
await Deno.writeTextFile(state, "{}");
await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1);
await deleteAuthState(state);
await deleteAuthState(state);
await assertRejects(() => Deno.stat(state), Deno.errors.NotFound);
await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import { assertEquals, assertThrows } from "@std/assert";
import { parseArguments } from "../cli.ts";
import { isVisibleUiErrorText } from "../src/capture.ts";
Deno.test("CLI parses bounded capture filters", () => {
const parsed = parseArguments([
"capture",
"--scenario",
"scenario.json",
"--personas",
"owner,non-owner",
"--headed",
]);
assertEquals(parsed.command, "capture");
assertEquals(parsed.values.get("personas"), ["owner,non-owner"]);
assertEquals(parsed.flags.has("headed"), true);
});
Deno.test("visible UI error classification ignores ordinary status text", () => {
assertEquals(isVisibleUiErrorText("Refresh failed (401 Unauthorized)"), true);
assertEquals(isVisibleUiErrorText("Workspace list loaded"), false);
});
Deno.test("CLI rejects positional and missing option values", () => {
assertThrows(() => parseArguments(["capture", "scenario.json"]), Error, "unexpected argument");
});
+172
View File
@@ -0,0 +1,172 @@
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
import { join } from "@std/path";
import {
assertBundleIsSecretFree,
redactText,
safeUrl,
writePrivateJson,
} from "../src/artifacts.ts";
import {
PROCESS_LOG_BYTE_LIMIT,
startOwnedProcesses,
stopOwnedProcesses,
} from "../src/processes.ts";
Deno.test("redaction removes common credentials and query values", () => {
const redacted = redactText(
"Authorization: Bearer abc.def cookie=session-value token=secret-value",
["abc.def"],
);
assertStringIncludes(redacted, "[REDACTED]");
assertEquals(redacted.includes("abc.def"), false);
assertEquals(redacted.includes("session-value"), false);
assertEquals(
safeUrl("https://user:pass@example.test/path?token=secret#fragment"),
"https://example.test/path?token=%5BREDACTED%5D",
);
assertRejects(
async () => assertBundleIsSecretFree('{"authorization":"Bearer abc"}'),
Error,
"forbidden secret marker",
);
});
Deno.test("private JSON state uses owner-only permissions", async () => {
const directory = await Deno.makeTempDir();
try {
const path = join(directory, "state", "owner.json");
await writePrivateJson(path, { cookies: [], origins: [] });
assertEquals(JSON.parse(await Deno.readTextFile(path)), { cookies: [], origins: [] });
if (Deno.build.os !== "windows") assertEquals((await Deno.stat(path)).mode! & 0o777, 0o600);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("owned process is terminated and its logs are redacted", async () => {
const directory = await Deno.makeTempDir();
const scenario = join(directory, "scenario.json");
await Deno.writeTextFile(scenario, "{}");
try {
const processes = await startOwnedProcesses(
[{
id: "fixture",
command: Deno.execPath(),
args: ["eval", 'console.log("authorization: secret-value"); setInterval(() => {}, 1000)'],
}],
scenario,
join(directory, "logs"),
["secret-value"],
);
assertEquals(processes.length, 1);
const logPath = join(directory, "logs", "fixture.stdout.log");
for (let attempt = 0; attempt < 20; attempt++) {
try {
if ((await Deno.readTextFile(logPath)).length > 0) break;
} catch {
// The output pump creates the file asynchronously.
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
const diagnostics = await stopOwnedProcesses(processes);
assertEquals(diagnostics, []);
const log = await Deno.readTextFile(logPath);
assertEquals(log.includes("secret-value"), false);
assertStringIncludes(log, "[REDACTED]");
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
assertEquals(metadata.truncated, false);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("owned process logs stop at the byte limit and record truncation", async () => {
const directory = await Deno.makeTempDir();
const scenario = join(directory, "scenario.json");
await Deno.writeTextFile(scenario, "{}");
try {
const processes = await startOwnedProcesses(
[{
id: "large-output",
command: Deno.execPath(),
args: [
"eval",
`console.log("x".repeat(${
PROCESS_LOG_BYTE_LIMIT + 32_768
})); setInterval(() => {}, 1000)`,
],
}],
scenario,
join(directory, "logs"),
[],
);
const logPath = join(directory, "logs", "large-output.stdout.log");
for (let attempt = 0; attempt < 100; attempt++) {
try {
if ((await Deno.stat(logPath)).size >= PROCESS_LOG_BYTE_LIMIT) break;
} catch {
// The output pump creates the file asynchronously.
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
assertEquals(await stopOwnedProcesses(processes), []);
assertEquals((await Deno.stat(logPath)).size, PROCESS_LOG_BYTE_LIMIT);
const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`));
assertEquals(metadata.byteLimit, PROCESS_LOG_BYTE_LIMIT);
assertEquals(metadata.truncated, true);
assertEquals(metadata.bytesWritten, PROCESS_LOG_BYTE_LIMIT);
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("forced cleanup terminates a TERM-resistant descendant", async () => {
if (Deno.build.os === "windows") return;
const directory = await Deno.makeTempDir();
const scenario = join(directory, "scenario.json");
const childPidPath = join(directory, "child.pid");
await Deno.writeTextFile(scenario, "{}");
try {
const childProgram = 'Deno.addSignalListener("SIGTERM", () => {}); setInterval(() => {}, 1000)';
const parentProgram = `
const child = new Deno.Command(Deno.execPath(), {
args: ["eval", ${JSON.stringify(childProgram)}],
stdout: "null",
stderr: "null"
}).spawn();
Deno.writeTextFileSync(Deno.args[0], String(child.pid));
Deno.addSignalListener("SIGTERM", () => {});
setInterval(() => {}, 1000);
`;
const processes = await startOwnedProcesses(
[{
id: "process-tree",
command: Deno.execPath(),
args: ["eval", parentProgram, childPidPath],
}],
scenario,
join(directory, "logs"),
[],
);
let childPid = 0;
for (let attempt = 0; attempt < 100; attempt++) {
try {
childPid = Number(await Deno.readTextFile(childPidPath));
if (childPid > 0) break;
} catch {
// The fixture publishes its descendant PID after spawn.
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
assertEquals(childPid > 0, true);
assertEquals(await stopOwnedProcesses(processes), []);
const status = await new Deno.Command("ps", {
args: ["-p", String(childPid), "-o", "pid="],
stdout: "piped",
stderr: "null",
}).output();
assertEquals(new TextDecoder().decode(status.stdout).trim(), "");
} finally {
await Deno.remove(directory, { recursive: true });
}
});
+113
View File
@@ -0,0 +1,113 @@
import { assertEquals, assertRejects, assertThrows } from "@std/assert";
import { join } from "@std/path";
import { cleanup } from "../src/lifecycle.ts";
import {
interpolateEnvironment,
loadScenario,
resolveScenarioPath,
validateBaseUrl,
} from "../src/scenario.ts";
function minimalScenario(extra = ""): string {
return `{
"schemaVersion": 1,
"id": "test-screen",
"title": "Test screen",
"baseUrl": "http://127.0.0.1:5173",
"personas": [{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}],
"viewports": [{"label":"desktop","width":1000,"height":800}],
"routes": [{
"id":"home","label":"Home","path":"/","goal":"Inspect home",
"dataState":"Fixture data","ready":{"kind":"selector","selector":"main"},
"capturePoints":[{"id":"initial","label":"Initial"}]
}]${extra}
}`;
}
Deno.test("scenario parser preserves explicit visual review context", async () => {
const directory = await Deno.makeTempDir();
try {
const path = join(directory, "scenario.json");
await Deno.writeTextFile(path, minimalScenario());
const scenario = await loadScenario(path);
assertEquals(scenario.personas[0].auth, { kind: "anonymous" });
assertEquals(scenario.routes[0].goal, "Inspect home");
assertEquals(scenario.routes[0].dataState, "Fixture data");
assertEquals(scenario.routes[0].ready, {
kind: "selector",
selector: "main",
timeoutMs: undefined,
});
assertEquals(scenario.reducedMotion, "reduce");
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("scenario parser rejects duplicate persona identity", async () => {
const directory = await Deno.makeTempDir();
try {
const path = join(directory, "scenario.json");
await Deno.writeTextFile(
path,
minimalScenario().replace(
'[{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}]',
'[{"id":"same","label":"First","auth":{"kind":"anonymous"}},{"id":"same","label":"Second","auth":{"kind":"anonymous"}}]',
),
);
await assertRejects(() => loadScenario(path), Error, "duplicate id: same");
} finally {
await Deno.remove(directory, { recursive: true });
}
});
Deno.test("base URL rejects embedded credentials and non-http schemes", () => {
assertThrows(
() => validateBaseUrl("https://user:secret@example.test"),
Error,
"must not contain credentials",
);
assertThrows(() => validateBaseUrl("file:///tmp/index.html"), Error, "must use http or https");
});
Deno.test("environment interpolation fails closed", () => {
assertEquals(
interpolateEnvironment("/w/${WORKSPACE_ID}", { WORKSPACE_ID: "W-test" }),
"/w/W-test",
);
assertThrows(
() => interpolateEnvironment("${MISSING}", {}),
Error,
"required environment variable is missing",
);
});
Deno.test("committed auth profiles resolve outside the repository", async () => {
const source = "scenarios/workspace-control-plane.json";
const scenario = await loadScenario(source);
for (const persona of scenario.personas) {
if (persona.auth.kind !== "storage-state") continue;
const statePath = resolveScenarioPath(source, persona.auth.path);
assertEquals(statePath.startsWith(Deno.cwd()), false);
}
});
Deno.test("cleanup removes only complete review bundles beyond retention", async () => {
const directory = await Deno.makeTempDir();
try {
for (const name of ["one", "two", "three"]) {
const run = join(directory, name);
await Deno.mkdir(run);
await Deno.writeTextFile(join(run, "review-context.json"), "{}");
await new Promise((resolve) => setTimeout(resolve, 5));
}
const unrelated = join(directory, "auth");
await Deno.mkdir(unrelated);
await Deno.writeTextFile(join(unrelated, "state.json"), "secret");
const removed = await cleanup({ outputDirectory: directory, keep: 1 });
assertEquals(removed.length, 2);
assertEquals(await Deno.readTextFile(join(unrelated, "state.json")), "secret");
} finally {
await Deno.remove(directory, { recursive: true });
}
});
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 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@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.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/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.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/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -81,6 +81,64 @@ export type WorkspaceResponse = {
extension_points: WorkspaceExtensionPoints;
};
export type WorkspaceMetadataSettingsResponse = {
workspace_id: string;
display_name: string;
created_at: string;
revision: string;
source: string;
diagnostics: Array<Diagnostic>;
};
export type UpdateWorkspaceMetadataRequest = {
display_name: string;
revision: string;
};
export type WorkspaceMetadataMutationResponse = {
workspace: WorkspaceMetadataSettingsResponse;
diagnostics: Array<Diagnostic>;
};
export type ProfileSettingsResponse = {
workspace_id: string;
registry_revision: string;
config_revision?: number | null;
tree_digest?: string | null;
projection_digest?: string | null;
default_profile?: string | null;
profiles: Array<WorkspaceProfileSummary>;
sources: Array<WorkspaceProfileSourceSummary>;
diagnostics: Array<Diagnostic>;
};
export type WorkspaceProfileSummary = {
profile_id: string;
selector: string;
label: string;
source_kind: string;
profile_source_id?: string | null;
description?: string | null;
editable: boolean;
is_default: boolean;
diagnostics: Array<Diagnostic>;
};
export type WorkspaceProfileSourceSummary = {
profile_source_id: string;
display_path: string;
kind: string;
content_type: string;
content_digest: string;
provenance: WorkspaceProfileSourceProvenance;
editable: boolean;
revision: string;
size_bytes: number;
diagnostics: Array<Diagnostic>;
};
export type WorkspaceProfileSourceProvenance = "project_profile_source_tree";
export type RepositorySourceKind =
| "local_path"
| "file"
@@ -1,8 +1,6 @@
export type Diagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
};
import type { Diagnostic as WorkspaceApiDiagnostic } from "$lib/generated/workspace-api";
export type Diagnostic = WorkspaceApiDiagnostic;
export type SettingsSectionId =
| "runtimes"
@@ -1,69 +1,335 @@
import type {
Diagnostic,
DiagnosticSeverity,
ProfileSettingsResponse,
UpdateWorkspaceMetadataRequest,
WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse,
} from "./profile-types";
WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary,
WorkspaceProfileSummary,
} from "$lib/generated/workspace-api";
export type WorkspaceProfileApi = {
getMetadata(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse>;
updateMetadata(
workspaceId: string,
displayName: string,
expectedRevision: string,
): Promise<WorkspaceMetadataMutationResponse>;
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
export class ProfileApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ProfileApiError";
}
}
type JsonRecord = Record<string, unknown>;
function record(value: unknown, context: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return value as JsonRecord;
}
function exactKeys(
value: JsonRecord,
required: readonly string[],
optional: readonly string[],
context: string,
): void {
const allowed = new Set([...required, ...optional]);
if (
required.some((key) => !(key in value)) ||
Object.keys(value).some((key) => !allowed.has(key))
) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
}
function stringValue(value: unknown, context: string): string {
if (typeof value !== "string") {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return value;
}
function booleanValue(value: unknown, context: string): boolean {
if (typeof value !== "boolean") {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return value;
}
function optionalString(
value: unknown,
context: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return stringValue(value, context);
}
function optionalRevision(
value: unknown,
context: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return value as number;
}
function arrayValue<T>(
value: unknown,
parser: (item: unknown) => T,
context: string,
): T[] {
if (!Array.isArray(value)) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return value.map(parser);
}
function parseDiagnostic(value: unknown): Diagnostic {
const item = record(value, "Workspace settings");
exactKeys(item, ["code", "severity", "message"], [], "Workspace settings");
const severity = stringValue(item.severity, "Workspace settings");
if (!(["info", "warning", "error"] as string[]).includes(severity)) {
throw new ProfileApiError(
"Workspace settings returned an invalid response.",
502,
);
}
return {
code: stringValue(item.code, "Workspace settings"),
severity: severity as DiagnosticSeverity,
message: stringValue(item.message, "Workspace settings"),
};
}
async function requestJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
export function parseWorkspaceMetadataSettingsResponse(
value: unknown,
): WorkspaceMetadataSettingsResponse {
const item = record(value, "Workspace metadata");
exactKeys(
item,
[
"workspace_id",
"display_name",
"created_at",
"revision",
"source",
"diagnostics",
],
[],
"Workspace metadata",
);
return {
workspace_id: stringValue(item.workspace_id, "Workspace metadata"),
display_name: stringValue(item.display_name, "Workspace metadata"),
created_at: stringValue(item.created_at, "Workspace metadata"),
revision: stringValue(item.revision, "Workspace metadata"),
source: stringValue(item.source, "Workspace metadata"),
diagnostics: arrayValue(
item.diagnostics,
parseDiagnostic,
"Workspace metadata",
),
};
}
export function parseWorkspaceMetadataMutationResponse(
value: unknown,
): WorkspaceMetadataMutationResponse {
const item = record(value, "Workspace metadata update");
exactKeys(
item,
["workspace", "diagnostics"],
[],
"Workspace metadata update",
);
return {
workspace: parseWorkspaceMetadataSettingsResponse(item.workspace),
diagnostics: arrayValue(
item.diagnostics,
parseDiagnostic,
"Workspace metadata update",
),
};
}
function parseWorkspaceProfileSummary(value: unknown): WorkspaceProfileSummary {
const item = record(value, "Profile catalog");
exactKeys(
item,
[
"profile_id",
"selector",
"label",
"source_kind",
"editable",
"is_default",
"diagnostics",
],
["profile_source_id", "description"],
"Profile catalog",
);
return {
profile_id: stringValue(item.profile_id, "Profile catalog"),
selector: stringValue(item.selector, "Profile catalog"),
label: stringValue(item.label, "Profile catalog"),
source_kind: stringValue(item.source_kind, "Profile catalog"),
profile_source_id: optionalString(
item.profile_source_id,
"Profile catalog",
),
description: optionalString(item.description, "Profile catalog"),
editable: booleanValue(item.editable, "Profile catalog"),
is_default: booleanValue(item.is_default, "Profile catalog"),
diagnostics: arrayValue(
item.diagnostics,
parseDiagnostic,
"Profile catalog",
),
};
}
function parseWorkspaceProfileSourceSummary(
value: unknown,
): WorkspaceProfileSourceSummary {
const item = record(value, "Profile source catalog");
exactKeys(
item,
[
"profile_source_id",
"display_path",
"kind",
"content_type",
"content_digest",
"provenance",
"editable",
"revision",
"size_bytes",
"diagnostics",
],
[],
"Profile source catalog",
);
const provenance = stringValue(item.provenance, "Profile source catalog");
if (provenance !== "project_profile_source_tree") {
throw new ProfileApiError(
"Profile source catalog returned an invalid response.",
502,
);
}
const sizeBytes = optionalRevision(item.size_bytes, "Profile source catalog");
if (sizeBytes === undefined || sizeBytes === null) {
throw new ProfileApiError(
"Profile source catalog returned an invalid response.",
502,
);
}
return {
profile_source_id: stringValue(
item.profile_source_id,
"Profile source catalog",
),
display_path: stringValue(item.display_path, "Profile source catalog"),
kind: stringValue(item.kind, "Profile source catalog"),
content_type: stringValue(item.content_type, "Profile source catalog"),
content_digest: stringValue(item.content_digest, "Profile source catalog"),
provenance: provenance as WorkspaceProfileSourceProvenance,
editable: booleanValue(item.editable, "Profile source catalog"),
revision: stringValue(item.revision, "Profile source catalog"),
size_bytes: sizeBytes,
diagnostics: arrayValue(
item.diagnostics,
parseDiagnostic,
"Profile source catalog",
),
};
}
export function parseProfileSettingsResponse(
value: unknown,
): ProfileSettingsResponse {
const item = record(value, "Profile settings");
exactKeys(
item,
["workspace_id", "registry_revision", "profiles", "sources", "diagnostics"],
["config_revision", "tree_digest", "projection_digest", "default_profile"],
"Profile settings",
);
return {
workspace_id: stringValue(item.workspace_id, "Profile settings"),
registry_revision: stringValue(item.registry_revision, "Profile settings"),
config_revision: optionalRevision(item.config_revision, "Profile settings"),
tree_digest: optionalString(item.tree_digest, "Profile settings"),
projection_digest: optionalString(
item.projection_digest,
"Profile settings",
),
default_profile: optionalString(item.default_profile, "Profile settings"),
profiles: arrayValue(
item.profiles,
parseWorkspaceProfileSummary,
"Profile settings",
),
sources: arrayValue(
item.sources,
parseWorkspaceProfileSourceSummary,
"Profile settings",
),
diagnostics: arrayValue(
item.diagnostics,
parseDiagnostic,
"Profile settings",
),
};
}
async function parseResponse<T>(
response: Response,
parser: (value: unknown) => T,
): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error(`request failed: ${response.status}`);
throw new ProfileApiError(
(await response.text()) || response.statusText,
response.status,
);
}
return (await response.json()) as T;
return parser(await response.json() as unknown);
}
export async function fetchWorkspaceMetadataSettings(
export async function fetchWorkspaceMetadata(
workspaceId: string,
): Promise<WorkspaceMetadataSettingsResponse> {
return await requestJson<WorkspaceMetadataSettingsResponse>(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
return await parseResponse(
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`),
parseWorkspaceMetadataSettingsResponse,
);
}
export async function updateWorkspaceMetadataSettings(
export async function updateWorkspaceMetadata(
workspaceId: string,
request: { display_name: string; revision: string },
request: UpdateWorkspaceMetadataRequest,
): Promise<WorkspaceMetadataMutationResponse> {
return await requestJson<WorkspaceMetadataMutationResponse>(
return await parseResponse(
await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
),
parseWorkspaceMetadataMutationResponse,
);
}
export async function fetchProfileSettings(
workspaceId: string,
): Promise<ProfileSettingsResponse> {
return await requestJson<ProfileSettingsResponse>(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
return await parseResponse(
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`),
parseProfileSettingsResponse,
);
}
export function createWorkspaceProfileApi(): WorkspaceProfileApi {
return {
getMetadata: fetchWorkspaceMetadataSettings,
async updateMetadata(workspaceId, displayName, expectedRevision) {
return await updateWorkspaceMetadataSettings(workspaceId, {
display_name: displayName,
revision: expectedRevision,
});
},
getProfiles: fetchProfileSettings,
};
}
@@ -1,52 +0,0 @@
import type { Diagnostic } from "./model";
export type WorkspaceMetadataSettingsResponse = {
workspace_id: string;
display_name: string;
created_at: string;
revision: string;
source: string;
diagnostics: Diagnostic[];
};
export type WorkspaceMetadataMutationResponse = {
workspace: WorkspaceMetadataSettingsResponse;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSummary = {
profile_id: string;
selector: string;
label: string;
source_kind: "builtin" | "project" | string;
profile_source_id?: string | null;
description?: string | null;
editable: boolean;
is_default: boolean;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceSummary = {
profile_source_id: string;
display_path: string;
kind: "virtual_config" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean;
revision: string;
size_bytes: number;
diagnostics: Diagnostic[];
};
export type ProfileSettingsResponse = {
workspace_id: string;
registry_revision: string;
config_revision?: number | null;
tree_digest?: string | null;
projection_digest?: string | null;
default_profile?: string | null;
profiles: WorkspaceProfileSummary[];
sources: WorkspaceProfileSourceSummary[];
diagnostics: Diagnostic[];
};
@@ -1,8 +1,8 @@
<script lang="ts">
import DiagnosticsList from "$lib/workspace/settings/DiagnosticsList.svelte";
import { settingsSectionHref } from "$lib/workspace/settings/model";
import type { ProfileSettingsResponse } from "$lib/generated/workspace-api";
import { fetchProfileSettings } from "$lib/workspace/settings/profile-api";
import type { ProfileSettingsResponse } from "$lib/workspace/settings/profile-types";
import type { PageProps } from "./$types";
let { data }: PageProps = $props();
@@ -1,11 +1,13 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceMetadataSettingsResponse,
} from '$lib/generated/workspace-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadataSettings,
updateWorkspaceMetadataSettings
fetchWorkspaceMetadata,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { Diagnostic } from '$lib/workspace/settings/model';
import type { WorkspaceMetadataSettingsResponse } from '$lib/workspace/settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -28,7 +30,7 @@
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadataSettings(workspaceId);
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
@@ -53,7 +55,7 @@
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadataSettings(workspaceId, {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
+181 -32
View File
@@ -2,45 +2,194 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { createWorkspaceProfileApi } from "../src/lib/workspace/settings/profile-api.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
Deno.test("workspace profile API delegates metadata calls to current route contract", async () => {
function assertThrows<T extends Error>(
fn: () => unknown,
errorType: abstract new (...args: never[]) => T,
): void {
try {
fn();
} catch (error) {
if (error instanceof errorType) return;
throw error;
}
throw new Error(`expected ${errorType.name} to be thrown`);
}
import {
fetchProfileSettings,
fetchWorkspaceMetadata,
parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse,
ProfileApiError,
updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts";
const diagnostic = {
code: "ok",
severity: "info",
message: "ready",
};
function profileSettingsFixture(): Record<string, unknown> {
return {
workspace_id: "workspace 1",
registry_revision: "config-source:7:tree:projection",
config_revision: 7,
tree_digest: "tree",
projection_digest: "projection",
default_profile: "workspace:coder",
profiles: [{
profile_id: "workspace:coder",
selector: "workspace:coder",
label: "Coder",
source_kind: "project",
profile_source_id: "profile-source-1",
description: null,
editable: true,
is_default: true,
diagnostics: [],
}],
sources: [{
profile_source_id: "profile-source-1",
display_path: "profiles/coder.dcdl",
kind: "profile",
content_type: "text/x-decodal",
content_digest: "sha256:source",
provenance: "project_profile_source_tree",
editable: false,
revision: "config-source:7",
size_bytes: 128,
diagnostics: [],
}],
diagnostics: [diagnostic],
};
}
Deno.test("profile settings requests use scoped API and strictly validate responses", async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ input: string; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ input: String(input), init });
return Promise.resolve(Response.json({
workspace_id: "workspace-a",
display_name: "Alpha",
revision: "revision-2",
}));
}) as typeof fetch;
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), init });
return Promise.resolve(Response.json(profileSettingsFixture()));
};
try {
const api = createWorkspaceProfileApi();
await api.getMetadata("workspace-a");
await api.updateMetadata("workspace-a", "Alpha updated", "revision-1");
const response = await fetchProfileSettings("workspace 1");
assertEquals(response.config_revision, 7);
assertEquals(response.sources[0].provenance, "project_profile_source_tree");
assertEquals(requests.length, 1);
assertEquals(requests[0].url, "/api/w/workspace%201/settings/profiles");
assertEquals(requests[0].init, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
if (requests.length !== 2) throw new Error("expected two metadata requests");
if (
requests.some((request) => request.input.includes("/settings/metadata"))
) {
throw new Error("obsolete metadata endpoint was used");
}
if (
requests.some((request) => !request.input.endsWith("/settings/workspace"))
) {
throw new Error("current Workspace settings endpoint was not used");
}
const updateBody = JSON.parse(String(requests[1].init?.body));
if (
updateBody.display_name !== "Alpha updated" ||
updateBody.revision !== "revision-1" ||
"expected_revision" in updateBody
) {
throw new Error(`unexpected update payload: ${JSON.stringify(updateBody)}`);
Deno.test("workspace metadata requests use generated DTO shapes", async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; init?: RequestInit }> = [];
const workspace = {
workspace_id: "workspace 1",
display_name: "Workspace",
created_at: "2026-01-01T00:00:00Z",
revision: "sha256:metadata",
source: "workspace-config",
diagnostics: [diagnostic],
};
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), init });
return Promise.resolve(
Response.json(
init?.method === "PUT" ? { workspace, diagnostics: [] } : workspace,
),
);
};
try {
assertEquals(
(await fetchWorkspaceMetadata("workspace 1")).revision,
"sha256:metadata",
);
assertEquals(
(await updateWorkspaceMetadata("workspace 1", {
display_name: "Renamed",
revision: "sha256:metadata",
})).workspace.workspace_id,
"workspace 1",
);
assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/workspace",
"/api/w/workspace%201/settings/workspace",
]);
assertEquals(requests[1].init?.method, "PUT");
assertEquals(
requests[1].init?.body,
JSON.stringify({ display_name: "Renamed", revision: "sha256:metadata" }),
);
} finally {
globalThis.fetch = originalFetch;
}
});
Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
const missing = profileSettingsFixture();
delete missing.profiles;
assertThrows(
() => parseProfileSettingsResponse(missing),
ProfileApiError,
);
const mistyped = profileSettingsFixture();
mistyped.config_revision = "7";
assertThrows(
() => parseProfileSettingsResponse(mistyped),
ProfileApiError,
);
const stale = profileSettingsFixture();
stale.legacy_profile_directory = ".yoi/profiles";
assertThrows(
() => parseProfileSettingsResponse(stale),
ProfileApiError,
);
const invalidProvenance = profileSettingsFixture();
const sources = invalidProvenance.sources as Array<Record<string, unknown>>;
sources[0].provenance = "filesystem";
assertThrows(
() => parseProfileSettingsResponse(invalidProvenance),
ProfileApiError,
);
});
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
assertThrows(
() =>
parseWorkspaceMetadataSettingsResponse({
workspace_id: "workspace-test",
display_name: "Workspace",
}),
ProfileApiError,
);
assertThrows(
() =>
parseWorkspaceMetadataSettingsResponse({
workspace_id: "workspace-test",
display_name: "Workspace",
created_at: "2026-01-01T00:00:00Z",
revision: "sha256:metadata",
source: "workspace-config",
diagnostics: [],
workspace_path: "/legacy/path",
}),
ProfileApiError,
);
});