Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,5 +1,87 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.37.17.0] — 2026-03-24
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Workspaces — workspace-first file storage for projects. Project files route
|
||||||
|
through the workspace FS subsystem (auto-created on first upload) instead of
|
||||||
|
the flat objStore path. Tree browser UI with nested folders, archive support
|
||||||
|
(zip/tar.gz upload and download), file actions (download, delete, mkdir), and
|
||||||
|
upload improvements (progress counter, archive detection, quota errors).
|
||||||
|
|
||||||
|
Architecture decision: `files` table + objStore = ephemeral message attachments;
|
||||||
|
Workspace FS = persistent file collections (tree, dirs, archive, quota, indexing).
|
||||||
|
"Save to workspace" bridge from chat files deferred to v0.37.18.
|
||||||
|
|
||||||
|
Also: extension track continuation scheduled as v0.39.x in roadmap, two new
|
||||||
|
design docs committed (multi-file Starlark, extension connections & libraries).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Workspace-backed project files:** `ensureProjectWorkspace()` auto-creates a
|
||||||
|
workspace (`owner_type=project`) on first file upload, sets `project.workspace_id`.
|
||||||
|
All project file operations route through workspace FS instead of objStore.
|
||||||
|
(`server/handlers/files.go`)
|
||||||
|
- **File download:** `GET /projects/:id/files/download?path=` streams raw file
|
||||||
|
content with `Content-Disposition: attachment`. (`server/handlers/files.go`)
|
||||||
|
- **File delete:** `DELETE /projects/:id/files?path=` with optional `recursive`
|
||||||
|
param for directories. (`server/handlers/files.go`)
|
||||||
|
- **Create directory:** `POST /projects/:id/files/mkdir` accepts path via JSON
|
||||||
|
body or query param. (`server/handlers/files.go`)
|
||||||
|
- **Archive upload:** `POST /projects/:id/archive/upload` extracts .zip/.tar.gz
|
||||||
|
into project workspace. Returns `files_extracted` count.
|
||||||
|
(`server/handlers/files.go`)
|
||||||
|
- **Archive download:** `GET /projects/:id/archive/download?format=zip` bundles
|
||||||
|
all project files into a zip/tar.gz. (`server/handlers/files.go`)
|
||||||
|
- **File browser UI:** Tree browser with nested folders (expand/collapse),
|
||||||
|
type icons, size display, on-hover delete button. Replaces the flat file list
|
||||||
|
from v0.37.16. (`src/js/sw/surfaces/projects/detail.js`)
|
||||||
|
- **File action bar:** "New folder" and "Download all" buttons above the file
|
||||||
|
tree when files exist. (`detail.js`)
|
||||||
|
- **Upload improvements:** Progress counter ("Uploading 3 of 7…"), archive
|
||||||
|
detection (`.zip`/`.tar.gz` → confirm extract dialog), specific error
|
||||||
|
messages (413 = quota exceeded). (`detail.js`)
|
||||||
|
- **SDK methods:** `deleteFile`, `mkdir`, `uploadArchive` added to
|
||||||
|
`sw.api.projects` domain. `files()` and `uploadFile()` updated to support
|
||||||
|
path param. (`src/js/sw/sdk/api-domains.js`)
|
||||||
|
- **File browser CSS:** 120 lines — file-row, file-chevron, file-icon,
|
||||||
|
file-name, file-meta, file-delete, dropzone, file-actions-bar.
|
||||||
|
(`src/css/sw-projects-surface.css`)
|
||||||
|
- **DESIGN-EXT-CONNECTIONS-LIBRARIES.md:** Extension connections (scoped
|
||||||
|
credentials) and library packages (shared code + data via `lib.load()`).
|
||||||
|
Three implementation phases. (`docs/`)
|
||||||
|
- **Extension track v0.39.x:** Roadmap updated — v0.39.0 multi-file Starlark,
|
||||||
|
v0.39.1 connections, v0.39.2 libraries, v0.39.3 full composition.
|
||||||
|
(`docs/ROADMAP.md`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Project file list response:** Now returns `WorkspaceFile` objects (with
|
||||||
|
`path`, `is_directory`, `content_type`, `size_bytes`) instead of `File` model
|
||||||
|
objects. `GET /projects/:id/files` supports `?path=` and `?recursive=` params.
|
||||||
|
- **FileHandler constructor:** Gains `SetWorkspaceFS()` method for workspace
|
||||||
|
injection. Project file routes use dedicated `projFileH` instance with wfs
|
||||||
|
attached. (`server/main.go`)
|
||||||
|
- **ICD updated:** `docs/ICD/projects.md` — expanded Project Files section with
|
||||||
|
all 7 endpoints, request/response schemas, error codes.
|
||||||
|
- **OpenAPI updated:** `server/static/openapi.yaml` — 5 new paths for project
|
||||||
|
file operations (download, delete, mkdir, archive upload/download).
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- **User default workspace:** Not yet implemented. Personal "My Files" deferred
|
||||||
|
to v0.37.18.
|
||||||
|
- **Tool output persistence:** `origin=tool_output` auto-save for LLM-generated
|
||||||
|
artifacts deferred to v0.37.18.
|
||||||
|
- **Save to workspace bridge:** "Save" action to promote ephemeral chat files
|
||||||
|
to workspace storage deferred to v0.37.18.
|
||||||
|
- **Workspace unique constraint:** Race guard on concurrent workspace creation
|
||||||
|
relies on DB error handling (re-read on duplicate key). Migration 014 for
|
||||||
|
explicit `UNIQUE(owner_type, owner_id)` not yet added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.37.16.0] — 2026-03-24
|
## [0.37.16.0] — 2026-03-24
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
532
docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md
Normal file
532
docs/DESIGN-EXT-CONNECTIONS-LIBRARIES.md
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
# DESIGN — Extension Connections & Library Packages
|
||||||
|
|
||||||
|
Two platform primitives. Extensions that talk to external services
|
||||||
|
need scoped credential management. Extensions that share logic and
|
||||||
|
data need a dependency mechanism with clean boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Extension Connections
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Extensions integrating with external services need credentials and
|
||||||
|
endpoint config. The current `settings` mechanism is flat key-value.
|
||||||
|
It breaks when a user works with two instances of the same service,
|
||||||
|
when teams share bot tokens alongside personal tokens, or when
|
||||||
|
multiple extensions need the same credentials.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
A **connection** concept. Same scope/resolution pattern as LLM
|
||||||
|
providers — scoped CRUD, resolution chain — but generic, owned by
|
||||||
|
package manifests, and sharable across packages.
|
||||||
|
|
||||||
|
#### Manifest Declaration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "git-board",
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"label": "Gitea Instance",
|
||||||
|
"fields": {
|
||||||
|
"base_url": {"type": "url", "required": true, "label": "Server URL"},
|
||||||
|
"api_token": {"type": "secret", "required": true, "label": "API Token"},
|
||||||
|
"org": {"type": "string", "required": false, "label": "Default Org"}
|
||||||
|
},
|
||||||
|
"scopes": ["global", "team", "personal"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"label": "GitHub",
|
||||||
|
"fields": {
|
||||||
|
"api_token": {"type": "secret", "required": true, "label": "Personal Access Token"},
|
||||||
|
"org": {"type": "string", "required": false, "label": "Default Org"}
|
||||||
|
},
|
||||||
|
"scopes": ["global", "personal"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `type` | Connection type identifier. Shared by name — if two packages declare `type: "gitea"`, users configure once and both packages resolve it. Prefix to namespace: `"git-board:internal"`. |
|
||||||
|
| `label` | Human-readable name for the UI. |
|
||||||
|
| `fields` | Config form schema. Types: `string`, `url`, `secret`, `number`, `boolean`, `select`. |
|
||||||
|
| `scopes` | Allowed scopes. Subset of `["global", "team", "personal"]`. |
|
||||||
|
|
||||||
|
#### Storage
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ext_connections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
package_id TEXT NOT NULL, -- declaring package (UI attribution)
|
||||||
|
scope TEXT NOT NULL, -- global | team | personal
|
||||||
|
owner_id TEXT NOT NULL, -- '' for global, team_id, or user_id
|
||||||
|
name TEXT NOT NULL, -- user label: "Work Gitea"
|
||||||
|
config TEXT NOT NULL, -- JSON, secrets encrypted at rest
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
UNIQUE(type, scope, owner_id, name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Secrets use the same vault pattern as `provider_configs.api_key`.
|
||||||
|
|
||||||
|
#### Resolution Chain
|
||||||
|
|
||||||
|
Personal → Team → Global. Same walk as provider resolution.
|
||||||
|
|
||||||
|
```python
|
||||||
|
conn = connections.get("gitea") # scope chain
|
||||||
|
conn = connections.get("gitea", name="Work Gitea") # explicit
|
||||||
|
all = connections.list("gitea") # all available
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
# Admin (global)
|
||||||
|
POST/GET/PUT/DELETE /api/v1/admin/connections[/:id]
|
||||||
|
|
||||||
|
# Team
|
||||||
|
POST/GET/PUT/DELETE /api/v1/teams/:teamId/connections[/:id]
|
||||||
|
|
||||||
|
# Personal
|
||||||
|
POST/GET/PUT/DELETE /api/v1/connections[/:id]
|
||||||
|
|
||||||
|
# Resolution
|
||||||
|
GET /api/v1/connections/resolve?type=gitea&name=Work+Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
Three surfaces matching the provider config pattern:
|
||||||
|
- **Admin → Connections** — global. Type picker from installed packages.
|
||||||
|
- **Team Admin → Connections** — team-scoped.
|
||||||
|
- **Settings → Connections** — personal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Library Packages
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Extensions duplicate everything. Two packages talking to Gitea write
|
||||||
|
the same API client, the same auth logic, the same pagination. If
|
||||||
|
they cache data, each creates its own tables with the same schema.
|
||||||
|
There's no mechanism for sharing code, data access, or connection
|
||||||
|
types across packages.
|
||||||
|
|
||||||
|
### Core Principle
|
||||||
|
|
||||||
|
**Libraries are services, not shared databases.**
|
||||||
|
|
||||||
|
A library owns its data privately. Consumers access it through
|
||||||
|
exported Starlark functions or REST endpoints — never by querying
|
||||||
|
the library's tables directly. The library controls validation,
|
||||||
|
access patterns, schema evolution, and data integrity. Consumers
|
||||||
|
are decoupled from the physical storage.
|
||||||
|
|
||||||
|
Same principle as microservices vs shared databases: share the
|
||||||
|
schema, share the coupling. Share the API, share the contract.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
A new package type: `library`. Libraries can provide:
|
||||||
|
|
||||||
|
- **Starlark functions** — server-side consumers call via `lib.load()`
|
||||||
|
- **REST endpoints** — browser-side consumers call via HTTP
|
||||||
|
- **Connection types** — inherited by consumers
|
||||||
|
- **Private DB tables** — optional, never exposed directly
|
||||||
|
- Any combination of the above
|
||||||
|
|
||||||
|
Libraries do **not** have: surfaces, LLM tools, or pipe filters.
|
||||||
|
|
||||||
|
#### Package Type Taxonomy
|
||||||
|
|
||||||
|
| Type | Surface | Starlark exports | REST endpoints | LLM tools | Private DB | Depends on libs |
|
||||||
|
|------|---------|-----------------|----------------|-----------|------------|-----------------|
|
||||||
|
| `surface` | ✓ | — | — | — | — | via REST |
|
||||||
|
| `extension` | — | — | — | ✓ | ✓ | ✓ |
|
||||||
|
| `library` | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||||
|
| `full` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
#### Library Manifest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "gitea-client",
|
||||||
|
"title": "Gitea API Client",
|
||||||
|
"type": "library",
|
||||||
|
"tier": "starlark",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shared Gitea client — connections, API, and data caching.",
|
||||||
|
|
||||||
|
"permissions": ["api.http", "db.read", "db.write"],
|
||||||
|
|
||||||
|
"exports": [
|
||||||
|
"get_repos", "sync_repos",
|
||||||
|
"get_issues", "get_issue", "create_issue", "update_issue",
|
||||||
|
"get_prs", "get_ci_status",
|
||||||
|
"search_cached_issues"
|
||||||
|
],
|
||||||
|
|
||||||
|
"api_routes": [
|
||||||
|
{"method": "GET", "path": "/repos"},
|
||||||
|
{"method": "GET", "path": "/issues"},
|
||||||
|
{"method": "GET", "path": "/issues/*"},
|
||||||
|
{"method": "POST", "path": "/issues"},
|
||||||
|
{"method": "GET", "path": "/prs"},
|
||||||
|
{"method": "GET", "path": "/ci/*"},
|
||||||
|
{"method": "POST", "path": "/sync"}
|
||||||
|
],
|
||||||
|
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"label": "Gitea Instance",
|
||||||
|
"fields": {
|
||||||
|
"base_url": {"type": "url", "required": true},
|
||||||
|
"api_token": {"type": "secret", "required": true},
|
||||||
|
"org": {"type": "string", "required": false}
|
||||||
|
},
|
||||||
|
"scopes": ["global", "team", "personal"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"db_tables": [
|
||||||
|
{
|
||||||
|
"name": "repos",
|
||||||
|
"columns": {
|
||||||
|
"connection_id": "text", "full_name": "text",
|
||||||
|
"owner": "text", "name": "text",
|
||||||
|
"description": "text", "html_url": "text",
|
||||||
|
"open_issues": "integer", "synced_at": "text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "issues",
|
||||||
|
"columns": {
|
||||||
|
"connection_id": "text", "repo_full_name": "text",
|
||||||
|
"number": "integer", "title": "text", "state": "text",
|
||||||
|
"labels": "text", "assignee": "text", "body": "text",
|
||||||
|
"created_at": "text", "synced_at": "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"schema_version": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Physical tables: `ext_gitea_client_repos`, `ext_gitea_client_issues`.
|
||||||
|
Private to the library. The db module remains package-scoped.
|
||||||
|
`physicalTable()` is unchanged — no cross-package table access.
|
||||||
|
|
||||||
|
#### script.star (library)
|
||||||
|
|
||||||
|
Two entry points. `exports` for Starlark consumers. `on_request`
|
||||||
|
for REST consumers. Both use the same internal logic. The DB is an
|
||||||
|
implementation detail.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
# Exported functions (Starlark consumers)
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
def get_repos(conn):
|
||||||
|
"""Fetch repos live from Gitea API."""
|
||||||
|
url = conn["base_url"] + "/api/v1/repos/search?limit=50"
|
||||||
|
resp = http.get(url=url, headers=_auth(conn))
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
return json.decode(resp["body"])
|
||||||
|
|
||||||
|
def sync_repos(conn):
|
||||||
|
"""Fetch repos and cache locally. Returns count."""
|
||||||
|
repos = get_repos(conn)
|
||||||
|
if repos == None:
|
||||||
|
return 0
|
||||||
|
conn_id = conn.get("id", "")
|
||||||
|
for row in db.query("repos", filters={"connection_id": conn_id}):
|
||||||
|
db.delete("repos", row["id"])
|
||||||
|
for r in repos:
|
||||||
|
db.insert("repos", {
|
||||||
|
"connection_id": conn_id,
|
||||||
|
"full_name": r.get("full_name", ""),
|
||||||
|
"owner": r.get("owner", {}).get("login", ""),
|
||||||
|
"name": r.get("name", ""),
|
||||||
|
"description": r.get("description", ""),
|
||||||
|
"html_url": r.get("html_url", ""),
|
||||||
|
"open_issues": int(r.get("open_issues_count", 0)),
|
||||||
|
"synced_at": "",
|
||||||
|
})
|
||||||
|
return len(repos)
|
||||||
|
|
||||||
|
def get_issues(conn, owner, repo, state="open"):
|
||||||
|
"""Fetch issues live from Gitea API."""
|
||||||
|
url = (conn["base_url"] + "/api/v1/repos/" + owner + "/" + repo
|
||||||
|
+ "/issues?state=" + state + "&limit=50&type=issues")
|
||||||
|
resp = http.get(url=url, headers=_auth(conn))
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
return json.decode(resp["body"])
|
||||||
|
|
||||||
|
def search_cached_issues(conn_id, repo_full_name, state=None):
|
||||||
|
"""Query locally cached issues. No API call."""
|
||||||
|
filters = {"connection_id": conn_id, "repo_full_name": repo_full_name}
|
||||||
|
if state:
|
||||||
|
filters["state"] = state
|
||||||
|
return db.query("issues", filters=filters, order="-synced_at", limit=200)
|
||||||
|
|
||||||
|
def create_issue(conn, owner, repo, title, body="", labels=None):
|
||||||
|
"""Create issue via Gitea API."""
|
||||||
|
payload = {"title": title}
|
||||||
|
if body:
|
||||||
|
payload["body"] = body
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = labels
|
||||||
|
url = conn["base_url"] + "/api/v1/repos/" + owner + "/" + repo + "/issues"
|
||||||
|
resp = http.post(url=url, body=json.encode(payload),
|
||||||
|
headers=_auth_json(conn))
|
||||||
|
if int(resp["status"]) >= 400:
|
||||||
|
return None
|
||||||
|
return json.decode(resp["body"])
|
||||||
|
|
||||||
|
# ... update_issue, get_prs, get_ci_status same pattern ...
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
# REST endpoints (browser / external)
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
def on_request(req):
|
||||||
|
path = req["path"]
|
||||||
|
method = req["method"]
|
||||||
|
conn = connections.get("gitea")
|
||||||
|
if not conn:
|
||||||
|
return _resp(400, {"error": "no gitea connection configured"})
|
||||||
|
|
||||||
|
if method == "GET" and path == "/repos":
|
||||||
|
return _resp(200, {"data": get_repos(conn) or []})
|
||||||
|
|
||||||
|
if method == "POST" and path == "/sync":
|
||||||
|
return _resp(200, {"synced": sync_repos(conn)})
|
||||||
|
|
||||||
|
if method == "GET" and path == "/issues":
|
||||||
|
q = req.get("query", {})
|
||||||
|
owner = _str(q.get("owner", ""))
|
||||||
|
repo = _str(q.get("repo", ""))
|
||||||
|
if owner and repo:
|
||||||
|
return _resp(200, {"data": get_issues(conn, owner, repo) or []})
|
||||||
|
return _resp(200, {"data": db.query("issues", order="-synced_at", limit=200)})
|
||||||
|
|
||||||
|
return _resp(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def _auth(conn):
|
||||||
|
return {"Authorization": "token " + conn.get("api_token", "")}
|
||||||
|
|
||||||
|
def _auth_json(conn):
|
||||||
|
return {"Authorization": "token " + conn.get("api_token", ""),
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def _resp(status, data):
|
||||||
|
return {"status": status, "body": json.encode(data),
|
||||||
|
"headers": {"Content-Type": "application/json"}}
|
||||||
|
|
||||||
|
def _str(v):
|
||||||
|
return str(v) if v != None else ""
|
||||||
|
```
|
||||||
|
|
||||||
|
The DB calls are all in the library's script. Consumers never see
|
||||||
|
the physical tables.
|
||||||
|
|
||||||
|
#### Consumer: Starlark Path
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ci-monitor",
|
||||||
|
"type": "extension",
|
||||||
|
"tier": "starlark",
|
||||||
|
"dependencies": {"gitea-client": ">=1.0.0"},
|
||||||
|
"tools": [{"name": "check_ci", "description": "Check CI status",
|
||||||
|
"parameters": {"owner": {"type": "string", "required": true},
|
||||||
|
"repo": {"type": "string", "required": true},
|
||||||
|
"ref": {"type": "string", "required": true}}}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
gitea = lib.load("gitea-client")
|
||||||
|
|
||||||
|
def on_tool_call(tool_name, params):
|
||||||
|
conn = connections.get("gitea")
|
||||||
|
if tool_name == "check_ci":
|
||||||
|
return gitea.get_ci_status(conn, params["owner"],
|
||||||
|
params["repo"], params["ref"])
|
||||||
|
return {"error": "unknown tool"}
|
||||||
|
```
|
||||||
|
|
||||||
|
No `db_tables`. No `connections` declaration. No `permissions` for
|
||||||
|
db access. The consumer calls functions.
|
||||||
|
|
||||||
|
#### Consumer: REST Path (browser-only)
|
||||||
|
|
||||||
|
A pure `type: "surface"` package calls the library's REST endpoints:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// git-board/js/main.js — no Starlark, no lib.load
|
||||||
|
var repos = await fetch(BASE + '/s/gitea-client/api/repos', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
await fetch(BASE + '/s/gitea-client/api/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This means `git-board` can be `type: "surface"` (browser-only).
|
||||||
|
Tools come from the library. Data comes from the library's REST API.
|
||||||
|
Connections come from the library. The surface just renders.
|
||||||
|
|
||||||
|
#### Permission Model
|
||||||
|
|
||||||
|
Libraries have their **own** permissions, granted by the admin:
|
||||||
|
|
||||||
|
- `gitea-client` gets `api.http` + `db.read` + `db.write`
|
||||||
|
- `ci-monitor` gets nothing beyond its own tools
|
||||||
|
|
||||||
|
When `ci-monitor` calls `gitea.get_ci_status(conn, ...)`, the
|
||||||
|
library function runs with the **library's** permissions. The
|
||||||
|
library can make HTTP calls and write to its own tables regardless
|
||||||
|
of what the consumer has.
|
||||||
|
|
||||||
|
The admin trusts the library (by granting permissions). The consumer
|
||||||
|
trusts the library (by declaring a dependency). The library trusts
|
||||||
|
nobody — it validates inputs and controls its own data.
|
||||||
|
|
||||||
|
#### `lib.load()` Mechanics
|
||||||
|
|
||||||
|
1. Check `ext_dependencies`: caller must declare dependency.
|
||||||
|
2. Load library's `script.star`.
|
||||||
|
3. Build library's module set using **library's own** permissions.
|
||||||
|
4. Execute in sandboxed context.
|
||||||
|
5. Extract globals listed in `exports`.
|
||||||
|
6. Return as frozen `starlarkstruct.Struct`.
|
||||||
|
7. Cache per sandbox invocation.
|
||||||
|
|
||||||
|
The library's `db` module resolves to `ext_gitea_client_*`.
|
||||||
|
The consumer's `db` module (if any) resolves to `ext_ci_monitor_*`.
|
||||||
|
No cross-contamination. `physicalTable()` unchanged.
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ext_dependencies (
|
||||||
|
consumer_id TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
version_spec TEXT NOT NULL,
|
||||||
|
resolved_ver TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (consumer_id, library_id),
|
||||||
|
FOREIGN KEY (consumer_id) REFERENCES package_registry(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (library_id) REFERENCES package_registry(id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ON DELETE RESTRICT`: cannot uninstall a library with consumers.
|
||||||
|
Libraries can depend on libraries. Circular deps rejected at install.
|
||||||
|
|
||||||
|
#### Schema Migrations
|
||||||
|
|
||||||
|
Libraries own their migrations. Consumers are unaffected. If a
|
||||||
|
migration changes internal tables, the library's exported functions
|
||||||
|
adapt. Private schema is NOT part of the API surface.
|
||||||
|
|
||||||
|
#### Breaking Changes
|
||||||
|
|
||||||
|
**API surface = exports + REST endpoints + connection types.**
|
||||||
|
|
||||||
|
- Remove/rename an export or endpoint → major version bump.
|
||||||
|
- Change a function's return shape → major version bump.
|
||||||
|
- Change connection type fields → major version bump.
|
||||||
|
- Add new exports, endpoints, fields → minor version bump.
|
||||||
|
- Internal DB changes, bug fixes → patch version bump.
|
||||||
|
|
||||||
|
Enforcement deferred. Social contract for now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Full Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ gitea-client (library) │
|
||||||
|
│ │
|
||||||
|
│ connections: gitea │
|
||||||
|
│ exports: get_repos, sync_repos, ... │
|
||||||
|
│ REST: /repos, /issues, /ci/*, /sync │
|
||||||
|
│ private DB: repos, issues (untouchable) │
|
||||||
|
│ │
|
||||||
|
│ permissions: api.http, db.read, db.write │
|
||||||
|
└────────┬────────────────┬────────────────┬───┘
|
||||||
|
│ │ │
|
||||||
|
lib.load() lib.load() fetch()
|
||||||
|
│ │ │
|
||||||
|
┌─────┴──────┐ ┌──────┴───────┐ ┌────┴──────┐
|
||||||
|
│ ci-monitor │ │ code-review │ │ git-board │
|
||||||
|
│ extension │ │ extension │ │ surface │
|
||||||
|
│ (starlark) │ │ (starlark) │ │ (browser) │
|
||||||
|
└────────────┘ └──────────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
One library. N consumers. Zero schema coupling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Extension Connections
|
||||||
|
|
||||||
|
Prerequisite: none.
|
||||||
|
|
||||||
|
- `ext_connections` table + store + handlers + UI
|
||||||
|
- `connections` Starlark module
|
||||||
|
- Three management surfaces (admin / team / personal)
|
||||||
|
|
||||||
|
### Phase 2: Library Packages
|
||||||
|
|
||||||
|
Prerequisite: none. Independent of Phase 1.
|
||||||
|
|
||||||
|
- `library` package type in installer
|
||||||
|
- `ext_dependencies` table
|
||||||
|
- `lib.load()` with per-library permission context
|
||||||
|
- Library `api_routes` (already works)
|
||||||
|
- Dependency resolution + uninstall protection
|
||||||
|
- Admin UI: dependency tree
|
||||||
|
|
||||||
|
### Phase 3: Full Composition
|
||||||
|
|
||||||
|
Prerequisite: Phase 1 + 2.
|
||||||
|
|
||||||
|
- Libraries declare connection types for consumers
|
||||||
|
- Build `gitea-client` reference library
|
||||||
|
- Migrate `git-board` to library-backed surface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not Covered
|
||||||
|
|
||||||
|
- **OAuth flows.** Static credentials only.
|
||||||
|
- **Package registry / marketplace.** `.pkg` archives, no central registry.
|
||||||
|
- **Runtime version pinning.** Installed version wins.
|
||||||
|
- **Cross-package events.** Functions and REST, not event subscriptions.
|
||||||
|
- **Library UI.** No surface. Config via package settings + connections.
|
||||||
@@ -223,15 +223,80 @@ Insert uses ON CONFLICT DO NOTHING (idempotent).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Files
|
## Project Files (v0.37.17 — workspace-backed)
|
||||||
|
|
||||||
|
Project files are stored in the project's workspace (auto-created on
|
||||||
|
first upload). The response uses `"files"` key, not `"data"`. Files
|
||||||
|
are `WorkspaceFile` objects with tree structure (directories + files).
|
||||||
|
|
||||||
|
### List Files
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /projects/:id/files → { "files": [...], "count": N }
|
GET /projects/:id/files → { "files": [...], "count": N }
|
||||||
POST /projects/:id/files ← multipart/form-data
|
?path=/docs&recursive=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Project-level file uploads (distinct from workspace files and channel
|
| Param | Default | Notes |
|
||||||
attachments). Note: `GET` uses `"files"` key, not `"data"`.
|
|-------|---------|-------|
|
||||||
|
| `path` | `""` | Directory to list (empty = root) |
|
||||||
|
| `recursive` | `true` | Include subdirectories |
|
||||||
|
|
||||||
|
### Upload File
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /projects/:id/files ← multipart/form-data
|
||||||
|
?path=/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Multipart form field: `file`. Optional `path` query param to upload
|
||||||
|
into a subdirectory. Auto-creates the project workspace on first upload.
|
||||||
|
Returns `201` with `{ path, filename, content_type, size_bytes }`.
|
||||||
|
|
||||||
|
**Errors:** `413` if file exceeds size limit or workspace quota.
|
||||||
|
|
||||||
|
### Download File
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/files/download?path=/docs/readme.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Streams raw file content with `Content-Disposition: attachment`.
|
||||||
|
|
||||||
|
### Delete File
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /projects/:id/files?path=/docs/readme.md
|
||||||
|
&recursive=false
|
||||||
|
```
|
||||||
|
|
||||||
|
`recursive=true` to delete directories with contents.
|
||||||
|
|
||||||
|
### Create Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /projects/:id/files/mkdir ← { "path": "/docs/images" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Path also accepted via `?path=` query param. Returns `201`.
|
||||||
|
|
||||||
|
### Upload Archive
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /projects/:id/archive/upload ← multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Multipart field: `file`. Accepts `.zip`, `.tar.gz`, `.tgz`.
|
||||||
|
Extracts contents into the workspace root.
|
||||||
|
Returns `{ ok: true, files_extracted: N }`.
|
||||||
|
|
||||||
|
### Download Archive
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/:id/archive/download?format=zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Bundles all project files into a zip (or `tar.gz`).
|
||||||
|
Streams with `Content-Disposition: attachment`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,18 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
|||||||
│ │ │
|
│ │ │
|
||||||
│ v0.37.1 Perm Audit ✅ │
|
│ v0.37.1 Perm Audit ✅ │
|
||||||
│ v0.37.2 Primitives ✅ │
|
│ v0.37.2 Primitives ✅ │
|
||||||
│ v0.37.3 SDK │
|
│ v0.37.3 SDK ✅ │
|
||||||
│ v0.37.4 Shell │
|
│ v0.37.4 Shell ✅ │
|
||||||
│ v0.37.5–14 Surfaces ✅ │
|
│ v0.37.5–16 Surfaces ✅ │
|
||||||
│ v0.37.15–18 Surfaces │
|
│ v0.37.17–19 Surfaces │
|
||||||
|
│ │ │
|
||||||
|
│ v0.38.x Workflow │
|
||||||
|
│ Product Maturity │
|
||||||
|
│ │ │
|
||||||
|
v0.39.0 │ │
|
||||||
|
v0.39.1 │ │
|
||||||
|
v0.39.2 │ │
|
||||||
|
v0.39.3 │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
══════╪════════════╪═════════════════╪══════
|
══════╪════════════╪═════════════════╪══════
|
||||||
│ MVP v0.50.0 │
|
│ MVP v0.50.0 │
|
||||||
@@ -185,7 +193,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
|
|||||||
| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
|
| v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
|
||||||
| v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue |
|
| v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue |
|
||||||
| v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link |
|
| v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link |
|
||||||
| v0.37.17 | Workspaces | File storage layer, archive upload/download (zip/tar), project file integration |
|
| v0.37.17 | Workspaces | Workspace-first project file storage, tree browser UI, archive upload/download |
|
||||||
| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
|
| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
|
||||||
| v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) |
|
| v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) |
|
||||||
|
|
||||||
@@ -213,21 +221,28 @@ storage deferred to v0.37.17.
|
|||||||
|
|
||||||
**v0.37.17 — Workspaces (File Storage):**
|
**v0.37.17 — Workspaces (File Storage):**
|
||||||
|
|
||||||
Workspace storage layer for project files. Archive (zip, tar, etc.)
|
Workspace-first file management for projects. Architecture: `files` table +
|
||||||
upload/download is **required for MVP** — projects need document attachment.
|
objStore = ephemeral message attachments; Workspace FS = persistent file
|
||||||
|
collections (tree, dirs, archive, quota, indexing). Bridge ("Save to workspace")
|
||||||
|
deferred to v0.37.18.
|
||||||
|
|
||||||
Schema and model FK already exist (`workspaces` table, `Project.WorkspaceID`).
|
Schema and model FK already exist (`workspaces` table, `Project.WorkspaceID`).
|
||||||
|
Workspace backend fully implemented (FS layer, handlers, store, SDK). This
|
||||||
|
version bridges projects to workspaces and builds the file browser UI.
|
||||||
Git integration (clone, pull, branch tracking) is optional/post-MVP.
|
Git integration (clone, pull, branch tracking) is optional/post-MVP.
|
||||||
|
|
||||||
- [ ] Workspace CRUD endpoints wiring (create-on-demand for projects)
|
- [x] Auto-create workspace for project on first file upload
|
||||||
- [ ] File upload backend: disk storage with workspace root path
|
- [x] Route project file uploads through workspace FS
|
||||||
- [ ] Archive upload: zip/tar extraction + individual file indexing
|
- [x] File browser UI in project sidebar (tree, folders, download, delete)
|
||||||
- [ ] File download: single file + archive download (zip bundle)
|
- [x] Archive upload: zip/tar extraction into workspace
|
||||||
- [ ] File browser UI in project sidebar (list, preview, delete)
|
- [x] Archive download: zip bundle of project files
|
||||||
- [ ] Storage quota enforcement (`max_bytes` on workspace)
|
- [x] Storage quota enforcement (`max_bytes` on workspace)
|
||||||
|
|
||||||
**v0.37.18 — Debug + Model Surface:**
|
**v0.37.18 — Debug + Model Surface:**
|
||||||
|
|
||||||
Debug modal Preact rebuild, model configuration UI, provider diagnostics.
|
Debug modal Preact rebuild, model configuration UI, provider diagnostics.
|
||||||
|
Also: user default workspace, `origin=tool_output` auto-save in completion
|
||||||
|
handler, "Save to workspace" bridge action on chat files/artifacts.
|
||||||
|
|
||||||
**v0.37.19 — Tag ("UI Complete"):**
|
**v0.37.19 — Tag ("UI Complete"):**
|
||||||
|
|
||||||
@@ -254,6 +269,21 @@ Visual workflow builder series. Bridges .37 plumbing to MVP gate
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.39.x — Extension Track Continuation
|
||||||
|
|
||||||
|
Resumes the extension track (v0.29–v0.31 ✅) with three new platform
|
||||||
|
primitives: multi-file Starlark packages, extension connections, and library
|
||||||
|
packages. See design docs for full specifications.
|
||||||
|
|
||||||
|
| Version | Summary | Design Doc |
|
||||||
|
|---------|---------|------------|
|
||||||
|
| v0.39.0 | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
|
||||||
|
| v0.39.1 | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
|
||||||
|
| v0.39.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. |
|
||||||
|
| v0.39.3 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library, migrate `git-board` to library-backed surface. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MVP v0.50.0
|
## MVP v0.50.0
|
||||||
|
|
||||||
**Gate:** deploy for 5–10 teams, ~50 users, 100+ anonymous visitors
|
**Gate:** deploy for 5–10 teams, ~50 users, 100+ anonymous visitors
|
||||||
|
|||||||
222
packages/sdk-test-runner/css/main.css
Normal file
222
packages/sdk-test-runner/css/main.css
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/* SDK Test Runner — Styles */
|
||||||
|
|
||||||
|
.sdkr-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.sdkr-header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
}
|
||||||
|
.sdkr-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3, #888);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.sdkr-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2, #aaa);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixtures panel */
|
||||||
|
.sdkr-fixtures {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg1, #1a1a1a);
|
||||||
|
}
|
||||||
|
.sdkr-fixture-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sdkr-fixture-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2, #aaa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sdkr-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg2, #222);
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
border: 1px solid var(--border, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
.sdkr-input {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg2, #222);
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
border: 1px solid var(--border, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.sdkr-input::placeholder {
|
||||||
|
color: var(--text3, #666);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.sdkr-fixture-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3, #888);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.sdkr-fixture-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3, #666);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.sdkr-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sdkr-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sdkr-filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2, #aaa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.sdkr-check {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.sdkr-check input { cursor: pointer; }
|
||||||
|
.sdkr-btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sdkr-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #444);
|
||||||
|
background: var(--bg2, #2a2a2a);
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.sdkr-btn:hover { background: var(--bg3, #383838); }
|
||||||
|
.sdkr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.sdkr-btn-primary {
|
||||||
|
background: var(--accent, #3b82f6);
|
||||||
|
border-color: var(--accent, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.sdkr-btn-primary:hover { filter: brightness(1.15); }
|
||||||
|
.sdkr-btn-danger {
|
||||||
|
background: var(--danger, #ef4444);
|
||||||
|
border-color: var(--danger, #ef4444);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary */
|
||||||
|
.sdkr-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg2, #1e1e1e);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
}
|
||||||
|
.sdkr-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.sdkr-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sdkr-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sdkr-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg2, #1e1e1e);
|
||||||
|
color: var(--text2, #aaa);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.sdkr-table td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, #2a2a2a);
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.sdkr-table tbody tr:hover {
|
||||||
|
background: var(--bg-hover, rgba(255,255,255,0.03));
|
||||||
|
}
|
||||||
|
.sdkr-test-name {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sdkr-verdict {
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.sdkr-ms {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3, #888);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sdkr-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3, #888);
|
||||||
|
max-width: 400px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row verdict highlights */
|
||||||
|
.sdkr-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
|
||||||
|
.sdkr-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
|
||||||
|
.sdkr-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
|
||||||
|
.sdkr-row-error td { background: rgba(239, 68, 68, 0.08); }
|
||||||
153
packages/sdk-test-runner/js/domains/admin.js
Normal file
153
packages/sdk-test-runner/js/domains/admin.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: admin
|
||||||
|
*
|
||||||
|
* Tests admin-scoped endpoints. Requires admin role.
|
||||||
|
* KNOWN ISSUES: some admin list endpoints use non-standard envelope
|
||||||
|
* keys (§E in ICD drift audit).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('admin', async function () {
|
||||||
|
|
||||||
|
// Guard: skip if not admin
|
||||||
|
if (!window.sw || !sw.isAdmin) {
|
||||||
|
await T.test('admin', 'guard', 'skip — not admin', async function () {
|
||||||
|
T.assert(true, 'skipped (user is not admin)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ──
|
||||||
|
|
||||||
|
await T.test('admin', 'users', 'sw.api.admin.users.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.users.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/users' },
|
||||||
|
validate: function (r) {
|
||||||
|
// KNOWN: backend returns {users: [...]} not {data: [...]}
|
||||||
|
var arr = r;
|
||||||
|
if (r && r.users) arr = r.users;
|
||||||
|
if (r && r.data) arr = r.data;
|
||||||
|
T.assert(Array.isArray(arr), 'expected array (maybe wrapped in non-standard key)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Configs (provider configs) ──
|
||||||
|
|
||||||
|
await T.test('admin', 'configs', 'sw.api.admin.configs.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.configs.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/configs' },
|
||||||
|
validate: function (r) {
|
||||||
|
// KNOWN: backend returns {configs: [...]} not {data: [...]}
|
||||||
|
var arr = r;
|
||||||
|
if (r && r.configs) arr = r.configs;
|
||||||
|
if (r && r.data) arr = r.data;
|
||||||
|
T.assert(Array.isArray(arr), 'expected array');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Models ──
|
||||||
|
|
||||||
|
await T.test('admin', 'models', 'sw.api.admin.models.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.models.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/models' },
|
||||||
|
validate: function (r) {
|
||||||
|
// KNOWN: backend returns {models: [...]} not {data: [...]}
|
||||||
|
var arr = r;
|
||||||
|
if (r && r.models) arr = r.models;
|
||||||
|
if (r && r.data) arr = r.data;
|
||||||
|
T.assert(Array.isArray(arr), 'expected array');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
|
||||||
|
await T.test('admin', 'settings', 'sw.api.admin.settings.get()', {
|
||||||
|
sdk: function () { return sw.api.admin.settings.get(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/settings' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Packages ──
|
||||||
|
|
||||||
|
await T.test('admin', 'packages', 'sw.api.admin.packages.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.packages.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/packages' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Surfaces ──
|
||||||
|
|
||||||
|
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.surfaces.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/surfaces' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extensions ──
|
||||||
|
|
||||||
|
await T.test('admin', 'extensions', 'sw.api.admin.extensions.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.extensions.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/extensions' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tasks ──
|
||||||
|
|
||||||
|
await T.test('admin', 'tasks', 'sw.api.admin.tasks.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.tasks.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/tasks' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Routing ──
|
||||||
|
|
||||||
|
await T.test('admin', 'routing', 'sw.api.admin.routing.policies()', {
|
||||||
|
sdk: function () { return sw.api.admin.routing.policies(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/routing/policies' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Provider health ──
|
||||||
|
|
||||||
|
await T.test('admin', 'providers', 'sw.api.admin.providers.health()', {
|
||||||
|
sdk: function () { return sw.api.admin.providers.health(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/providers/health' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Storage ──
|
||||||
|
|
||||||
|
await T.test('admin', 'storage', 'sw.api.admin.storage.status()', {
|
||||||
|
sdk: function () { return sw.api.admin.storage.status(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/storage/status' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Vault ──
|
||||||
|
|
||||||
|
await T.test('admin', 'vault', 'sw.api.admin.vault.status()', {
|
||||||
|
sdk: function () { return sw.api.admin.vault.status(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/vault/status' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Channels (archived) ──
|
||||||
|
|
||||||
|
await T.test('admin', 'channels', 'sw.api.admin.channels.archived()', {
|
||||||
|
sdk: function () { return sw.api.admin.channels.archived(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/channels/archived' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Projects ──
|
||||||
|
|
||||||
|
await T.test('admin', 'projects', 'sw.api.admin.projects.list()', {
|
||||||
|
sdk: function () { return sw.api.admin.projects.list(); },
|
||||||
|
raw: { method: 'GET', path: '/admin/projects' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
158
packages/sdk-test-runner/js/domains/channels.js
Normal file
158
packages/sdk-test-runner/js/domains/channels.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: channels
|
||||||
|
*
|
||||||
|
* Tests: list, create, get, update, delete, messages, participants,
|
||||||
|
* models, kbs, files, markRead, generateTitle, path, siblings.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('channels', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var channelId = null;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('channels', 'list', 'sw.api.channels.list() returns array', {
|
||||||
|
sdk: function () { return sw.api.channels.list(); },
|
||||||
|
raw: { method: 'GET', path: '/channels' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('channels', 'crud', 'sw.api.channels.create() returns channel shape', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.channels.create({ title: tag + '-ch', type: 'direct' });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/channels', body: { title: tag + '-ch-raw', type: 'direct' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.channel, 'channel');
|
||||||
|
channelId = r.id;
|
||||||
|
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('channels', 'crud', 'sw.api.channels.get(id) returns full shape', {
|
||||||
|
sdk: function () { return sw.api.channels.get(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.channelFull, 'channelFull');
|
||||||
|
T.assert(r.id === channelId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update ──
|
||||||
|
|
||||||
|
await T.test('channels', 'crud', 'sw.api.channels.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.channels.update(channelId, { title: tag + '-updated' }); },
|
||||||
|
raw: { method: 'PUT', path: '/channels/' + channelId, body: { title: tag + '-updated' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.channelFull, 'channel');
|
||||||
|
T.assert(r.title === tag + '-updated', 'title not updated');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Messages ──
|
||||||
|
|
||||||
|
await T.test('channels', 'messages', 'sw.api.channels.messages(id) returns array', {
|
||||||
|
sdk: function () { return sw.api.channels.messages(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/messages' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Send message ──
|
||||||
|
|
||||||
|
var msgId = null;
|
||||||
|
await T.test('channels', 'messages', 'sw.api.channels.send(id, data)', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.channels.send(channelId, { content: 'sdk-test-msg', role: 'user' });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/channels/' + channelId + '/messages',
|
||||||
|
body: { content: 'sdk-test-msg', role: 'user' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.message, 'message');
|
||||||
|
msgId = r.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Siblings ──
|
||||||
|
|
||||||
|
if (msgId) {
|
||||||
|
await T.test('channels', 'messages', 'sw.api.channels.siblings(id, msgId)', {
|
||||||
|
sdk: function () { return sw.api.channels.siblings(channelId, msgId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/messages/' + msgId + '/siblings' },
|
||||||
|
validate: function (r) {
|
||||||
|
// Siblings returns { siblings: [...], current_index, total }
|
||||||
|
var arr = r.siblings || T.unwrapList(r);
|
||||||
|
T.assert(Array.isArray(arr), 'expected siblings array');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path ──
|
||||||
|
|
||||||
|
await T.test('channels', 'tree', 'sw.api.channels.path(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.path(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/path' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mark Read ──
|
||||||
|
|
||||||
|
await T.test('channels', 'read', 'sw.api.channels.markRead(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.markRead(channelId); },
|
||||||
|
raw: { method: 'POST', path: '/channels/' + channelId + '/mark-read', body: {} },
|
||||||
|
validate: function () { /* 200 OK is sufficient */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KBs ──
|
||||||
|
|
||||||
|
await T.test('channels', 'kbs', 'sw.api.channels.kbs(id) returns array', {
|
||||||
|
sdk: function () { return sw.api.channels.kbs(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/knowledge-bases' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Files ──
|
||||||
|
|
||||||
|
await T.test('channels', 'files', 'sw.api.channels.files(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.files(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/files' },
|
||||||
|
validate: function (r) {
|
||||||
|
// May be array or {files: [], count: N}
|
||||||
|
T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Participants ──
|
||||||
|
|
||||||
|
await T.test('channels', 'participants', 'sw.api.channels.participants(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.participants(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/participants' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Models ──
|
||||||
|
|
||||||
|
await T.test('channels', 'models', 'sw.api.channels.models(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.models(channelId); },
|
||||||
|
raw: { method: 'GET', path: '/channels/' + channelId + '/models' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('channels', 'crud', 'sw.api.channels.del(id)', {
|
||||||
|
sdk: function () { return sw.api.channels.del(channelId); },
|
||||||
|
raw: { method: 'DELETE', path: '/channels/' + channelId },
|
||||||
|
validate: function () { channelId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
82
packages/sdk-test-runner/js/domains/knowledge.js
Normal file
82
packages/sdk-test-runner/js/domains/knowledge.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: knowledge
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('knowledge', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var kbId = null;
|
||||||
|
|
||||||
|
await T.test('knowledge', 'list', 'sw.api.knowledge.list()', {
|
||||||
|
sdk: function () { return sw.api.knowledge.list(); },
|
||||||
|
raw: { method: 'GET', path: '/knowledge-bases' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('knowledge', 'crud', 'sw.api.knowledge.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.knowledge.create({ name: tag + '-kb', scope: 'personal' });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/knowledge-bases', body: { name: tag + '-kb', scope: 'personal' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.kb, 'kb');
|
||||||
|
kbId = r.id;
|
||||||
|
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!kbId) return;
|
||||||
|
|
||||||
|
await T.test('knowledge', 'crud', 'sw.api.knowledge.get(id)', {
|
||||||
|
sdk: function () { return sw.api.knowledge.get(kbId); },
|
||||||
|
raw: { method: 'GET', path: '/knowledge-bases/' + kbId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.kb, 'kb');
|
||||||
|
T.assert(r.id === kbId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('knowledge', 'crud', 'sw.api.knowledge.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.knowledge.update(kbId, { name: tag + '-kb-upd' }); },
|
||||||
|
raw: { method: 'PUT', path: '/knowledge-bases/' + kbId, body: { name: tag + '-kb-upd' } },
|
||||||
|
validate: function (r) { T.assertShape(r, T.S.kb, 'kb'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Discoverable ──
|
||||||
|
|
||||||
|
await T.test('knowledge', 'discover', 'sw.api.knowledge.discoverable()', {
|
||||||
|
sdk: function () { return sw.api.knowledge.discoverable(); },
|
||||||
|
raw: { method: 'GET', path: '/knowledge-bases-discoverable' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Documents ──
|
||||||
|
|
||||||
|
await T.test('knowledge', 'docs', 'sw.api.knowledge.documents(id)', {
|
||||||
|
sdk: function () { return sw.api.knowledge.documents(kbId); },
|
||||||
|
raw: { method: 'GET', path: '/knowledge-bases/' + kbId + '/documents' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search ──
|
||||||
|
|
||||||
|
// Search requires an embedding provider — only run if fixture provisioned one
|
||||||
|
var hasEmbedding = T.fixtures && T.fixtures.embeddingModel;
|
||||||
|
await T.test('knowledge', 'search', 'sw.api.knowledge.search(id, query)', {
|
||||||
|
sdk: function () { return sw.api.knowledge.search(kbId, 'test', 5); },
|
||||||
|
raw: { method: 'POST', path: '/knowledge-bases/' + kbId + '/search',
|
||||||
|
body: { query: 'test', limit: 5 } },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); },
|
||||||
|
skip: hasEmbedding ? null : 'no embedding provider configured — set up a provider with embedding models'
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('knowledge', 'crud', 'sw.api.knowledge.del(id)', {
|
||||||
|
sdk: function () { return sw.api.knowledge.del(kbId); },
|
||||||
|
raw: { method: 'DELETE', path: '/knowledge-bases/' + kbId },
|
||||||
|
validate: function () { kbId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
196
packages/sdk-test-runner/js/domains/misc.js
Normal file
196
packages/sdk-test-runner/js/domains/misc.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: misc
|
||||||
|
*
|
||||||
|
* Covers smaller domains: notifications, folders, profile,
|
||||||
|
* data portability, health, models, users, presence, usage, groups,
|
||||||
|
* tools, git.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('misc', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// HEALTH
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'health', 'sw.api.health()', {
|
||||||
|
sdk: function () { return sw.api.health(); },
|
||||||
|
raw: { method: 'GET', path: '/health' },
|
||||||
|
validate: function (r) { T.assert(r.status === 'ok', 'status should be ok'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PROFILE
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'profile', 'sw.api.profile.get()', {
|
||||||
|
sdk: function () { return sw.api.profile.get(); },
|
||||||
|
raw: { method: 'GET', path: '/profile' },
|
||||||
|
validate: function (r) { T.assertShape(r, T.S.profile, 'profile'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('misc', 'profile', 'sw.api.profile.permissions()', {
|
||||||
|
sdk: function () {
|
||||||
|
if (sw.api.profile.permissions) return sw.api.profile.permissions();
|
||||||
|
throw new Error('sw.api.profile.permissions not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'GET', path: '/profile/permissions' },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assert(Array.isArray(r.permissions), 'permissions should be array');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// MODELS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'models', 'sw.api.models.enabled()', {
|
||||||
|
sdk: function () {
|
||||||
|
if (sw.api.models && sw.api.models.enabled) return sw.api.models.enabled();
|
||||||
|
throw new Error('sw.api.models.enabled not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'GET', path: '/models/enabled' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// FOLDERS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
var folderId = null;
|
||||||
|
|
||||||
|
await T.test('misc', 'folders', 'sw.api.folders.list()', {
|
||||||
|
sdk: function () { return sw.api.folders.list(); },
|
||||||
|
raw: { method: 'GET', path: '/folders' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('misc', 'folders', 'sw.api.folders.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.folders.create(tag + '-folder');
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/folders', body: { name: tag + '-folder', parent_id: null, sort_order: 0 } },
|
||||||
|
validate: function (r) {
|
||||||
|
// Backend returns { folder: {...} } — SDK passes envelope through
|
||||||
|
var obj = r.folder || r;
|
||||||
|
T.assertShape(obj, T.S.folder, 'folder');
|
||||||
|
folderId = obj.id;
|
||||||
|
T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
await T.test('misc', 'folders', 'sw.api.folders.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.folders.update(folderId, { name: tag + '-fold-upd' }); },
|
||||||
|
raw: { method: 'PUT', path: '/folders/' + folderId, body: { name: tag + '-fold-upd' } },
|
||||||
|
validate: function (r) { T.assert(r && (r.ok || r.id || r.folder), 'expected ok or folder'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('misc', 'folders', 'sw.api.folders.del(id)', {
|
||||||
|
sdk: function () { return sw.api.folders.del(folderId); },
|
||||||
|
raw: { method: 'DELETE', path: '/folders/' + folderId },
|
||||||
|
validate: function () { folderId = null; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// NOTIFICATIONS (duplicate key bug fixed — prefs/setPref/delPref restored)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'notifications', 'sw.api.notifications.list()', {
|
||||||
|
sdk: function () { return sw.api.notifications.list(); },
|
||||||
|
raw: { method: 'GET', path: '/notifications' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('misc', 'notifications', 'sw.api.notifications.unreadCount()', {
|
||||||
|
sdk: function () { return sw.api.notifications.unreadCount(); },
|
||||||
|
raw: { method: 'GET', path: '/notifications/unread-count' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('misc', 'notifications', 'sw.api.notifications.prefs()', {
|
||||||
|
sdk: function () {
|
||||||
|
if (typeof sw.api.notifications.prefs === 'function') return sw.api.notifications.prefs();
|
||||||
|
throw new Error('sw.api.notifications.prefs is ' + typeof sw.api.notifications.prefs);
|
||||||
|
},
|
||||||
|
raw: { method: 'GET', path: '/notifications/preferences' },
|
||||||
|
validate: function () { /* existence is sufficient */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// USERS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'users', 'sw.api.users.search(q)', {
|
||||||
|
sdk: function () { return sw.api.users.search('admin'); },
|
||||||
|
raw: { method: 'GET', path: '/users/search?q=admin' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// PRESENCE
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'presence', 'sw.api.presence.heartbeat()', {
|
||||||
|
sdk: function () { return sw.api.presence.heartbeat(); },
|
||||||
|
raw: { method: 'POST', path: '/presence/heartbeat', body: {} },
|
||||||
|
validate: function () { /* 200 OK is sufficient */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// USAGE
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'usage', 'sw.api.usage.mine()', {
|
||||||
|
sdk: function () { return sw.api.usage.mine(); },
|
||||||
|
raw: { method: 'GET', path: '/usage' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// GROUPS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'groups', 'sw.api.groups.mine()', {
|
||||||
|
sdk: function () { return sw.api.groups.mine(); },
|
||||||
|
raw: { method: 'GET', path: '/groups/mine' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// TOOLS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'tools', 'sw.api.tools.list()', {
|
||||||
|
sdk: function () { return sw.api.tools.list(); },
|
||||||
|
raw: { method: 'GET', path: '/tools' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// DATA PORTABILITY (KNOWN BUG: deleteAccount wrong method+path)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
await T.test('misc', 'export', 'sw.api.dataPortability.exportMe()', {
|
||||||
|
sdk: function () { return sw.api.dataPortability.exportMe(); },
|
||||||
|
raw: { method: 'GET', path: '/export/me' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); },
|
||||||
|
skip: 'empty DB export may 500 — expected in dev'
|
||||||
|
});
|
||||||
|
|
||||||
|
// deleteAccount: SDK uses POST /profile/delete, backend is DELETE /me
|
||||||
|
// We don't actually call delete — just verify the method exists
|
||||||
|
await T.test('misc', 'export', 'sw.api.dataPortability.deleteAccount exists', async function () {
|
||||||
|
T.assert(typeof sw.api.dataPortability.deleteAccount === 'function',
|
||||||
|
'deleteAccount should be a function');
|
||||||
|
// NOTE: SDK calls POST /profile/delete — backend is DELETE /me.
|
||||||
|
// This is a known SDK_BUG from the ICD drift audit (§A).
|
||||||
|
// We don't actually invoke it because it would delete the account.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
106
packages/sdk-test-runner/js/domains/notes.js
Normal file
106
packages/sdk-test-runner/js/domains/notes.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: notes
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('notes', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var noteId = null;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('notes', 'list', 'sw.api.notes.list()', {
|
||||||
|
sdk: function () { return sw.api.notes.list(); },
|
||||||
|
raw: { method: 'GET', path: '/notes' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('notes', 'crud', 'sw.api.notes.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.notes.create({ title: tag + '-note', content: '# Test\nBody text' });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/notes', body: { title: tag + '-note', content: '# Test\nBody text' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.note, 'note');
|
||||||
|
noteId = r.id;
|
||||||
|
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!noteId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('notes', 'crud', 'sw.api.notes.get(id)', {
|
||||||
|
sdk: function () { return sw.api.notes.get(noteId); },
|
||||||
|
raw: { method: 'GET', path: '/notes/' + noteId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.note, 'note');
|
||||||
|
T.assert(r.id === noteId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update ──
|
||||||
|
|
||||||
|
await T.test('notes', 'crud', 'sw.api.notes.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.notes.update(noteId, { title: tag + '-note-upd', content: 'updated' }); },
|
||||||
|
raw: { method: 'PUT', path: '/notes/' + noteId, body: { title: tag + '-note-upd', content: 'updated' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.note, 'note');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search ──
|
||||||
|
|
||||||
|
await T.test('notes', 'search', 'sw.api.notes.search(query)', {
|
||||||
|
sdk: function () { return sw.api.notes.search(tag, 5); },
|
||||||
|
raw: { method: 'GET', path: '/notes/search?q=' + encodeURIComponent(tag) + '&limit=5' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search Titles ──
|
||||||
|
|
||||||
|
await T.test('notes', 'search', 'sw.api.notes.searchTitles(query)', {
|
||||||
|
sdk: function () { return sw.api.notes.searchTitles(tag, 5); },
|
||||||
|
raw: { method: 'GET', path: '/notes/search-titles?q=' + encodeURIComponent(tag) + '&limit=5' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Folders ──
|
||||||
|
|
||||||
|
await T.test('notes', 'folders', 'sw.api.notes.folders()', {
|
||||||
|
sdk: function () { return sw.api.notes.folders(); },
|
||||||
|
raw: { method: 'GET', path: '/notes/folders' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Backlinks ──
|
||||||
|
|
||||||
|
await T.test('notes', 'links', 'sw.api.notes.backlinks(id)', {
|
||||||
|
sdk: function () { return sw.api.notes.backlinks(noteId); },
|
||||||
|
raw: { method: 'GET', path: '/notes/' + noteId + '/backlinks' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Graph ──
|
||||||
|
|
||||||
|
await T.test('notes', 'graph', 'sw.api.notes.graph()', {
|
||||||
|
sdk: function () { return sw.api.notes.graph(); },
|
||||||
|
raw: { method: 'GET', path: '/notes/graph' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('notes', 'crud', 'sw.api.notes.del(id)', {
|
||||||
|
sdk: function () { return sw.api.notes.del(noteId); },
|
||||||
|
raw: { method: 'DELETE', path: '/notes/' + noteId },
|
||||||
|
validate: function () { noteId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
66
packages/sdk-test-runner/js/domains/personas.js
Normal file
66
packages/sdk-test-runner/js/domains/personas.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: personas
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('personas', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var personaId = null;
|
||||||
|
|
||||||
|
// Policy: allow_user_personas is toggled by fixtures.js.
|
||||||
|
// If fixtures didn't run (or user isn't admin), create will 403.
|
||||||
|
|
||||||
|
await T.test('personas', 'list', 'sw.api.personas.list()', {
|
||||||
|
sdk: function () { return sw.api.personas.list(); },
|
||||||
|
raw: { method: 'GET', path: '/personas' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('personas', 'crud', 'sw.api.personas.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.personas.create({
|
||||||
|
name: tag + '-persona', system_prompt: 'You are a test bot.',
|
||||||
|
scope: 'personal'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/personas', body: {
|
||||||
|
name: tag + '-persona', system_prompt: 'You are a test bot.',
|
||||||
|
scope: 'personal'
|
||||||
|
}},
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.persona, 'persona');
|
||||||
|
personaId = r.id;
|
||||||
|
T.registerCleanup(function () { if (personaId) return T.safeDelete('/personas/' + personaId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!personaId) return;
|
||||||
|
|
||||||
|
// NOTE: User-scoped personas only have LIST + CREATE routes.
|
||||||
|
// GET/PUT/DELETE /personas/:id are admin-only (ICD gap).
|
||||||
|
// Use admin endpoints for full CRUD testing.
|
||||||
|
|
||||||
|
await T.test('personas', 'crud', 'sw.api.admin.personas.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.admin.personas.update(personaId, { name: tag + '-pers-upd' }); },
|
||||||
|
raw: { method: 'PUT', path: '/admin/personas/' + personaId, body: { name: tag + '-pers-upd' } },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── KBs ──
|
||||||
|
|
||||||
|
await T.test('personas', 'kbs', 'sw.api.admin.personas.kbs(id)', {
|
||||||
|
sdk: function () { return sw.api.admin.personas.kbs(personaId); },
|
||||||
|
raw: { method: 'GET', path: '/admin/personas/' + personaId + '/knowledge-bases' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('personas', 'crud', 'sw.api.admin.personas.del(id)', {
|
||||||
|
sdk: function () { return sw.api.admin.personas.del(personaId); },
|
||||||
|
raw: { method: 'DELETE', path: '/admin/personas/' + personaId },
|
||||||
|
validate: function () { personaId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
134
packages/sdk-test-runner/js/domains/projects.js
Normal file
134
packages/sdk-test-runner/js/domains/projects.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: projects
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('projects', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var projectId = null;
|
||||||
|
var channelId = null;
|
||||||
|
var noteId = null;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('projects', 'list', 'sw.api.projects.list()', {
|
||||||
|
sdk: function () { return sw.api.projects.list(); },
|
||||||
|
raw: { method: 'GET', path: '/projects' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('projects', 'crud', 'sw.api.projects.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.projects.create({ name: tag + '-proj', description: 'sdk test' });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/projects', body: { name: tag + '-proj', description: 'sdk test' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.project, 'project');
|
||||||
|
projectId = r.id;
|
||||||
|
T.registerCleanup(function () { if (projectId) return T.safeDelete('/projects/' + projectId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('projects', 'crud', 'sw.api.projects.get(id)', {
|
||||||
|
sdk: function () { return sw.api.projects.get(projectId); },
|
||||||
|
raw: { method: 'GET', path: '/projects/' + projectId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.project, 'project');
|
||||||
|
T.assert(r.id === projectId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update ──
|
||||||
|
|
||||||
|
await T.test('projects', 'crud', 'sw.api.projects.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.projects.update(projectId, { name: tag + '-proj-upd' }); },
|
||||||
|
raw: { method: 'PUT', path: '/projects/' + projectId, body: { name: tag + '-proj-upd' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.project, 'project');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Channel association ──
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.addChannel()', async function () {
|
||||||
|
var ch = await T.raw.post('/channels', { title: tag + '-proj-ch', type: 'direct' });
|
||||||
|
channelId = ch.id;
|
||||||
|
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.addChannel(id, chId)', {
|
||||||
|
sdk: function () { return sw.api.projects.addChannel(projectId, channelId, 0); },
|
||||||
|
raw: { method: 'POST', path: '/projects/' + projectId + '/channels',
|
||||||
|
body: { channel_id: channelId, position: 0 } },
|
||||||
|
validate: function () { /* ok */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.channels(id)', {
|
||||||
|
sdk: function () { return sw.api.projects.channels(projectId); },
|
||||||
|
raw: { method: 'GET', path: '/projects/' + projectId + '/channels' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.removeChannel()', {
|
||||||
|
sdk: function () { return sw.api.projects.removeChannel(projectId, channelId); },
|
||||||
|
raw: { method: 'DELETE', path: '/projects/' + projectId + '/channels/' + channelId },
|
||||||
|
validate: function () { /* ok */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Note association ──
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'create scratch note for project', async function () {
|
||||||
|
var n = await T.raw.post('/notes', { title: tag + '-proj-note', content: 'test' });
|
||||||
|
noteId = n.id;
|
||||||
|
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.addNote()', {
|
||||||
|
sdk: function () { return sw.api.projects.addNote(projectId, noteId); },
|
||||||
|
raw: { method: 'POST', path: '/projects/' + projectId + '/notes',
|
||||||
|
body: { note_id: noteId } },
|
||||||
|
validate: function () { /* ok */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.notes(id)', {
|
||||||
|
sdk: function () { return sw.api.projects.notes(projectId); },
|
||||||
|
raw: { method: 'GET', path: '/projects/' + projectId + '/notes' },
|
||||||
|
validate: function (r) { T.assert(Array.isArray(r), 'expected array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('projects', 'assoc', 'sw.api.projects.removeNote()', {
|
||||||
|
sdk: function () { return sw.api.projects.removeNote(projectId, noteId); },
|
||||||
|
raw: { method: 'DELETE', path: '/projects/' + projectId + '/notes/' + noteId },
|
||||||
|
validate: function () { /* ok */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files ──
|
||||||
|
|
||||||
|
await T.test('projects', 'files', 'sw.api.projects.files(id)', {
|
||||||
|
sdk: function () { return sw.api.projects.files(projectId); },
|
||||||
|
raw: { method: 'GET', path: '/projects/' + projectId + '/files' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected obj/arr'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('projects', 'crud', 'sw.api.projects.del(id)', {
|
||||||
|
sdk: function () { return sw.api.projects.del(projectId); },
|
||||||
|
raw: { method: 'DELETE', path: '/projects/' + projectId },
|
||||||
|
validate: function () { projectId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
107
packages/sdk-test-runner/js/domains/tasks.js
Normal file
107
packages/sdk-test-runner/js/domains/tasks.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: tasks
|
||||||
|
*
|
||||||
|
* KNOWN BUGS:
|
||||||
|
* sw.api.tasks.start(id) → POST /tasks/:id/start (backend: /tasks/:id/run)
|
||||||
|
* sw.api.tasks.stop(id) → POST /tasks/:id/stop (backend: /tasks/:id/kill)
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('tasks', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var taskId = null;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'list', 'sw.api.tasks.list()', {
|
||||||
|
sdk: function () { return sw.api.tasks.list(); },
|
||||||
|
raw: { method: 'GET', path: '/tasks' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'crud', 'sw.api.tasks.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.tasks.create({
|
||||||
|
name: tag + '-task', task_type: 'prompt',
|
||||||
|
schedule: '@daily', user_prompt: 'SDK test prompt', is_active: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/tasks', body: {
|
||||||
|
name: tag + '-task', task_type: 'prompt',
|
||||||
|
schedule: '@daily', user_prompt: 'SDK test prompt', is_active: false
|
||||||
|
}},
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.task, 'task');
|
||||||
|
taskId = r.id;
|
||||||
|
T.registerCleanup(function () { if (taskId) return T.safeDelete('/tasks/' + taskId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'crud', 'sw.api.tasks.get(id)', {
|
||||||
|
sdk: function () { return sw.api.tasks.get(taskId); },
|
||||||
|
raw: { method: 'GET', path: '/tasks/' + taskId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assert(r.id === taskId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'crud', 'sw.api.tasks.update(id, data)', {
|
||||||
|
sdk: function () { return sw.api.tasks.update(taskId, { name: tag + '-task-upd' }); },
|
||||||
|
raw: { method: 'PUT', path: '/tasks/' + taskId, body: { name: tag + '-task-upd' } },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object', 'expected object'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'lifecycle', 'sw.api.tasks.start(id)', {
|
||||||
|
sdk: function () {
|
||||||
|
if (sw.api.tasks.start) return sw.api.tasks.start(taskId);
|
||||||
|
throw new Error('sw.api.tasks.start not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/tasks/' + taskId + '/run', body: {} },
|
||||||
|
validate: function () { /* 200/202 is sufficient */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stop ── (404 expected when no active run)
|
||||||
|
|
||||||
|
await T.test('tasks', 'lifecycle', 'sw.api.tasks.stop(id)', {
|
||||||
|
sdk: function () {
|
||||||
|
if (sw.api.tasks.stop) return sw.api.tasks.stop(taskId);
|
||||||
|
throw new Error('sw.api.tasks.stop not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/tasks/' + taskId + '/kill', body: {} },
|
||||||
|
validate: function () { /* 200/202 is sufficient */ },
|
||||||
|
skip: 'no active run to cancel — 404 expected without task execution'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Runs ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'runs', 'sw.api.tasks.runs(id)', {
|
||||||
|
sdk: function () {
|
||||||
|
if (sw.api.tasks.runs) return sw.api.tasks.runs(taskId);
|
||||||
|
throw new Error('sw.api.tasks.runs not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'GET', path: '/tasks/' + taskId + '/runs' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('tasks', 'crud', 'sw.api.tasks.del(id)', {
|
||||||
|
sdk: function () { return sw.api.tasks.del(taskId); },
|
||||||
|
raw: { method: 'DELETE', path: '/tasks/' + taskId },
|
||||||
|
validate: function () { taskId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
93
packages/sdk-test-runner/js/domains/workflows.js
Normal file
93
packages/sdk-test-runner/js/domains/workflows.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: workflows
|
||||||
|
*
|
||||||
|
* Tests: list, create, get, update, delete, stages.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('workflows', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var wfId = null;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('workflows', 'list', 'sw.api.workflows.list() returns array', {
|
||||||
|
sdk: function () { return sw.api.workflows.list(); },
|
||||||
|
raw: { method: 'GET', path: '/workflows' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('workflows', 'crud', 'sw.api.workflows.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.workflows.create({
|
||||||
|
name: tag + '-wf', slug: tag + '-wf',
|
||||||
|
entry_mode: 'team_only', scope: 'personal'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/workflows', body: {
|
||||||
|
name: tag + '-wf', slug: tag + '-wf',
|
||||||
|
entry_mode: 'team_only', scope: 'personal'
|
||||||
|
}},
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workflow, 'workflow');
|
||||||
|
wfId = r.id;
|
||||||
|
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wfId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('workflows', 'crud', 'sw.api.workflows.get(id)', {
|
||||||
|
sdk: function () { return sw.api.workflows.get(wfId); },
|
||||||
|
raw: { method: 'GET', path: '/workflows/' + wfId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workflow, 'workflow');
|
||||||
|
T.assert(r.id === wfId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update (KNOWN BUG: SDK uses PUT, backend expects PATCH) ──
|
||||||
|
|
||||||
|
await T.test('workflows', 'crud', 'sw.api.workflows.update(id, data)', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.workflows.update(wfId, { name: tag + '-wf-updated' });
|
||||||
|
},
|
||||||
|
raw: { method: 'PATCH', path: '/workflows/' + wfId,
|
||||||
|
body: { name: tag + '-wf-updated' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workflow, 'workflow');
|
||||||
|
T.assert(r.name === tag + '-wf-updated', 'name not updated');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stages ──
|
||||||
|
|
||||||
|
var stageId = null;
|
||||||
|
|
||||||
|
// stages.list via SDK (if wired) vs raw
|
||||||
|
await T.test('workflows', 'stages', 'GET /workflows/:id/stages (raw — SDK may not have this)', {
|
||||||
|
sdk: function () {
|
||||||
|
// SDK may or may not expose stages — test raw path
|
||||||
|
if (sw.api.workflows.stages) return sw.api.workflows.stages(wfId);
|
||||||
|
throw new Error('sw.api.workflows.stages not defined');
|
||||||
|
},
|
||||||
|
raw: { method: 'GET', path: '/workflows/' + wfId + '/stages' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('workflows', 'crud', 'sw.api.workflows.del(id)', {
|
||||||
|
sdk: function () { return sw.api.workflows.del(wfId); },
|
||||||
|
raw: { method: 'DELETE', path: '/workflows/' + wfId },
|
||||||
|
validate: function () { wfId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
83
packages/sdk-test-runner/js/domains/workspaces.js
Normal file
83
packages/sdk-test-runner/js/domains/workspaces.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: workspaces
|
||||||
|
*
|
||||||
|
* Tests: list, create, get, update, delete, files.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('workspaces', async function () {
|
||||||
|
var tag = 'sdkr-' + Date.now();
|
||||||
|
var wsId = null;
|
||||||
|
|
||||||
|
// Get current user for owner_type/owner_id
|
||||||
|
var me = await sw.api.profile.get();
|
||||||
|
var userId = me.id;
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'list', 'sw.api.workspaces.list()', {
|
||||||
|
sdk: function () { return sw.api.workspaces.list(); },
|
||||||
|
raw: { method: 'GET', path: '/workspaces' },
|
||||||
|
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.workspaces.create({ name: tag + '-ws', owner_type: 'user', owner_id: userId });
|
||||||
|
},
|
||||||
|
raw: { method: 'POST', path: '/workspaces', body: { name: tag + '-ws', owner_type: 'user', owner_id: userId } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workspace, 'workspace');
|
||||||
|
wsId = r.id;
|
||||||
|
T.registerCleanup(function () { if (wsId) return T.safeDelete('/workspaces/' + wsId); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wsId) return;
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'crud', 'sw.api.workspaces.get(id)', {
|
||||||
|
sdk: function () { return sw.api.workspaces.get(wsId); },
|
||||||
|
raw: { method: 'GET', path: '/workspaces/' + wsId },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workspace, 'workspace');
|
||||||
|
T.assert(r.id === wsId, 'id mismatch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update (KNOWN BUG: SDK uses PUT, backend expects PATCH) ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'crud', 'sw.api.workspaces.update(id, data)', {
|
||||||
|
sdk: function () {
|
||||||
|
return sw.api.workspaces.update(wsId, { name: tag + '-ws-updated' });
|
||||||
|
},
|
||||||
|
raw: { method: 'PATCH', path: '/workspaces/' + wsId,
|
||||||
|
body: { name: tag + '-ws-updated' } },
|
||||||
|
validate: function (r) {
|
||||||
|
T.assertShape(r, T.S.workspace, 'workspace');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Files list ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'files', 'sw.api.workspaces.files(id)', {
|
||||||
|
sdk: function () { return sw.api.workspaces.files(wsId); },
|
||||||
|
raw: { method: 'GET', path: '/workspaces/' + wsId + '/files' },
|
||||||
|
validate: function (r) { T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
|
||||||
|
await T.test('workspaces', 'crud', 'sw.api.workspaces.del(id)', {
|
||||||
|
sdk: function () { return sw.api.workspaces.del(wsId); },
|
||||||
|
raw: { method: 'DELETE', path: '/workspaces/' + wsId },
|
||||||
|
validate: function () { wsId = null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
239
packages/sdk-test-runner/js/fixtures.js
Normal file
239
packages/sdk-test-runner/js/fixtures.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Fixtures
|
||||||
|
*
|
||||||
|
* Provisions reusable test resources before domains run and tears
|
||||||
|
* them down afterward. Modeled on the ICD test runner's fixture
|
||||||
|
* pattern.
|
||||||
|
*
|
||||||
|
* Fixture resources (all optional — domains degrade gracefully):
|
||||||
|
*
|
||||||
|
* providerConfig Global API config created from the first admin
|
||||||
|
* config that has at least one embedding model.
|
||||||
|
* Enables: knowledge.search, model preference tests.
|
||||||
|
*
|
||||||
|
* embeddingModel {model_id, provider_config_id} picked from the
|
||||||
|
* enabled models list after the config is created.
|
||||||
|
*
|
||||||
|
* policies Policy toggles applied at setup, reverted at
|
||||||
|
* teardown (e.g. allow_user_personas).
|
||||||
|
*
|
||||||
|
* team Test team (with current user as admin).
|
||||||
|
*
|
||||||
|
* Access: window.SDKR.fixtures.*
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.fixtures = {
|
||||||
|
ready: false,
|
||||||
|
providerConfig: null, // { id, name, provider }
|
||||||
|
embeddingModel: null, // { model_id, provider_config_id }
|
||||||
|
team: null, // { id, name }
|
||||||
|
policies: {}, // key → original value (for revert)
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Policy helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function setPolicy(key, value) {
|
||||||
|
var fix = T.fixtures;
|
||||||
|
try {
|
||||||
|
// Save original for revert
|
||||||
|
if (!(key in fix.policies)) {
|
||||||
|
try {
|
||||||
|
var settings = await T.raw.get('/admin/settings');
|
||||||
|
fix.policies[key] = settings.policies?.[key] ?? null;
|
||||||
|
} catch (_) {
|
||||||
|
fix.policies[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await T.raw.put('/admin/settings/' + key, { value: String(value) });
|
||||||
|
} catch (e) {
|
||||||
|
fix.errors.push('policy ' + key + ': ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revertPolicies() {
|
||||||
|
var fix = T.fixtures;
|
||||||
|
var keys = Object.keys(fix.policies);
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
var val = fix.policies[keys[i]];
|
||||||
|
try {
|
||||||
|
await T.raw.put('/admin/settings/' + keys[i], {
|
||||||
|
value: val === null ? 'false' : String(val)
|
||||||
|
});
|
||||||
|
} catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider / model discovery ──────────────────────────
|
||||||
|
|
||||||
|
async function provisionProvider() {
|
||||||
|
var fix = T.fixtures;
|
||||||
|
|
||||||
|
// ── Read UI inputs (provider + API key) ──
|
||||||
|
var ui = T._fixtureInput || {};
|
||||||
|
var selectedProvider = ui.provSelect ? ui.provSelect.value : '';
|
||||||
|
var apiKey = ui.keyInput ? ui.keyInput.value.trim() : '';
|
||||||
|
var statusEl = ui.statusEl;
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
if (statusEl) statusEl.textContent = msg;
|
||||||
|
console.log('[SDKR:fixtures] ' + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any configs already exist
|
||||||
|
try {
|
||||||
|
var configs = await T.raw.get('/admin/configs');
|
||||||
|
var list = configs.data || configs.configs || [];
|
||||||
|
if (list.length > 0) {
|
||||||
|
fix.providerConfig = { id: list[0].id, name: list[0].name, provider: list[0].provider };
|
||||||
|
setStatus('Using existing config: ' + list[0].name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fix.errors.push('config list: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no config exists and user provided a provider + key, create one
|
||||||
|
if (!fix.providerConfig && selectedProvider && apiKey) {
|
||||||
|
try {
|
||||||
|
var configData = {
|
||||||
|
name: 'sdkr-fixture-' + selectedProvider,
|
||||||
|
provider: selectedProvider,
|
||||||
|
api_key: apiKey,
|
||||||
|
scope: 'global'
|
||||||
|
};
|
||||||
|
setStatus('Creating ' + selectedProvider + ' config...');
|
||||||
|
var created = await T.raw.post('/admin/configs', configData);
|
||||||
|
if (created && created.id) {
|
||||||
|
fix.providerConfig = { id: created.id, name: configData.name, provider: selectedProvider };
|
||||||
|
fix._createdConfig = true; // mark for cleanup
|
||||||
|
setStatus('Config created: ' + configData.name);
|
||||||
|
T.registerCleanup(function () {
|
||||||
|
if (fix._createdConfig && fix.providerConfig) {
|
||||||
|
return T.safeDelete('/admin/configs/' + fix.providerConfig.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Config create failed: ' + e.message);
|
||||||
|
fix.errors.push('config create: ' + e.message);
|
||||||
|
}
|
||||||
|
} else if (!fix.providerConfig && selectedProvider && !apiKey) {
|
||||||
|
// Provider selected but no key — try creating without key (some providers
|
||||||
|
// use server-side keys configured via env vars)
|
||||||
|
try {
|
||||||
|
var configData2 = {
|
||||||
|
name: 'sdkr-fixture-' + selectedProvider,
|
||||||
|
provider: selectedProvider,
|
||||||
|
scope: 'global'
|
||||||
|
};
|
||||||
|
var created2 = await T.raw.post('/admin/configs', configData2);
|
||||||
|
if (created2 && created2.id) {
|
||||||
|
fix.providerConfig = { id: created2.id, name: configData2.name, provider: selectedProvider };
|
||||||
|
fix._createdConfig = true;
|
||||||
|
setStatus('Config created (server key): ' + configData2.name);
|
||||||
|
T.registerCleanup(function () {
|
||||||
|
if (fix._createdConfig && fix.providerConfig) {
|
||||||
|
return T.safeDelete('/admin/configs/' + fix.providerConfig.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setStatus('No config — skipping model tests');
|
||||||
|
}
|
||||||
|
} else if (!fix.providerConfig) {
|
||||||
|
setStatus('No provider selected — model tests will be skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch models if we have a config
|
||||||
|
if (fix.providerConfig) {
|
||||||
|
try {
|
||||||
|
await T.raw.post('/admin/models/fetch', {});
|
||||||
|
// Wait briefly for async model fetch
|
||||||
|
await new Promise(function (r) { setTimeout(r, 1500); });
|
||||||
|
} catch (_) { /* fetch may fail if no key */ }
|
||||||
|
|
||||||
|
// Look for an embedding model
|
||||||
|
try {
|
||||||
|
var models = await T.raw.get('/models/enabled');
|
||||||
|
var list2 = models.data || [];
|
||||||
|
for (var m = 0; m < list2.length; m++) {
|
||||||
|
var caps = list2[m].capabilities || {};
|
||||||
|
if (caps.embedding || list2[m].model_type === 'embedding') {
|
||||||
|
fix.embeddingModel = {
|
||||||
|
model_id: list2[m].model_id,
|
||||||
|
provider_config_id: list2[m].provider_config_id
|
||||||
|
};
|
||||||
|
console.log('[SDKR:fixtures] Embedding model: ' + list2[m].model_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* no models available */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Team ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function provisionTeam() {
|
||||||
|
var fix = T.fixtures;
|
||||||
|
try {
|
||||||
|
var team = await T.raw.post('/admin/teams', {
|
||||||
|
name: 'sdkr-fixture-' + Date.now(),
|
||||||
|
description: 'SDK test runner fixture team'
|
||||||
|
});
|
||||||
|
if (team && team.id) {
|
||||||
|
fix.team = { id: team.id, name: team.name };
|
||||||
|
T.registerCleanup(function () {
|
||||||
|
if (fix.team) return T.safeDelete('/admin/teams/' + fix.team.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fix.errors.push('team: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provision / Teardown ────────────────────────────────
|
||||||
|
|
||||||
|
T.provisionFixtures = async function () {
|
||||||
|
var fix = T.fixtures;
|
||||||
|
if (fix.ready) return;
|
||||||
|
|
||||||
|
console.log('[SDKR:fixtures] Provisioning...');
|
||||||
|
|
||||||
|
// Policies
|
||||||
|
await setPolicy('allow_user_personas', 'true');
|
||||||
|
|
||||||
|
// Provider + models
|
||||||
|
await provisionProvider();
|
||||||
|
|
||||||
|
// Team
|
||||||
|
await provisionTeam();
|
||||||
|
|
||||||
|
fix.ready = true;
|
||||||
|
|
||||||
|
if (fix.errors.length > 0) {
|
||||||
|
console.warn('[SDKR:fixtures] ' + fix.errors.length + ' errors:', fix.errors);
|
||||||
|
}
|
||||||
|
console.log('[SDKR:fixtures] Ready —',
|
||||||
|
'config:', fix.providerConfig ? fix.providerConfig.name : 'none',
|
||||||
|
'| embedding:', fix.embeddingModel ? fix.embeddingModel.model_id : 'none',
|
||||||
|
'| team:', fix.team ? fix.team.name : 'none'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
T.teardownFixtures = async function () {
|
||||||
|
await revertPolicies();
|
||||||
|
// Cleanup functions registered via T.registerCleanup handle
|
||||||
|
// the config + team deletion.
|
||||||
|
T.fixtures = {
|
||||||
|
ready: false, providerConfig: null, embeddingModel: null,
|
||||||
|
team: null, policies: {}, errors: []
|
||||||
|
};
|
||||||
|
console.log('[SDKR:fixtures] Torn down');
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
357
packages/sdk-test-runner/js/framework.js
Normal file
357
packages/sdk-test-runner/js/framework.js
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Framework
|
||||||
|
*
|
||||||
|
* Dual-path validation: every test runs through the SDK first. If it
|
||||||
|
* fails, a raw fetch against the ICD-specified endpoint isolates the
|
||||||
|
* fault:
|
||||||
|
*
|
||||||
|
* SDK pass → PASS (both layers good)
|
||||||
|
* SDK fail + ICD pass → SDK_BUG (wrong method/path/unwrap)
|
||||||
|
* SDK fail + ICD fail → ICD_BUG (backend broken)
|
||||||
|
* Validate fail → SHAPE_BUG (response doesn't match ICD contract)
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
// ─── Results ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.results = [];
|
||||||
|
T.cleanup = [];
|
||||||
|
T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 };
|
||||||
|
T.running = false;
|
||||||
|
T.aborted = false;
|
||||||
|
|
||||||
|
// ─── Assertion Library ────────────────────────────────────
|
||||||
|
|
||||||
|
function assertType(val, type, path) {
|
||||||
|
var actual = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val;
|
||||||
|
if (type === 'array') { if (!Array.isArray(val)) return path + ': expected array, got ' + actual; }
|
||||||
|
else if (type === 'uuid') { if (typeof val !== 'string' || val.length < 8) return path + ': expected uuid'; }
|
||||||
|
else if (type === 'string?') { if (val !== null && val !== undefined && typeof val !== 'string') return path + ': expected string|null, got ' + actual; }
|
||||||
|
else if (type === 'number?') { if (val !== null && val !== undefined && typeof val !== 'number') return path + ': expected number|null, got ' + actual; }
|
||||||
|
else if (type === 'bool') { if (typeof val !== 'boolean') return path + ': expected bool, got ' + actual; }
|
||||||
|
else if (type === 'object') { if (typeof val !== 'object' || val === null || Array.isArray(val)) return path + ': expected object, got ' + actual; }
|
||||||
|
else if (type === 'object?') { if (val !== null && val !== undefined && (typeof val !== 'object' || Array.isArray(val))) return path + ': expected object|null, got ' + actual; }
|
||||||
|
else if (type === 'any') { /* always passes */ }
|
||||||
|
else { if (typeof val !== type) return path + ': expected ' + type + ', got ' + actual; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateShape(obj, schema, prefix) {
|
||||||
|
var errors = [];
|
||||||
|
prefix = prefix || '$';
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return [prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj)];
|
||||||
|
}
|
||||||
|
Object.keys(schema).forEach(function (key) {
|
||||||
|
var spec = schema[key];
|
||||||
|
var val = obj[key];
|
||||||
|
var path = prefix + '.' + key;
|
||||||
|
if (typeof spec === 'string') {
|
||||||
|
if (spec.charAt(spec.length - 1) === '?') {
|
||||||
|
if (val === undefined || val === null) return;
|
||||||
|
spec = spec.slice(0, -1);
|
||||||
|
} else if (val === undefined) {
|
||||||
|
errors.push(path + ': missing required field');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var e = assertType(val, spec, path);
|
||||||
|
if (e) errors.push(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
T.assert = function (condition, msg) {
|
||||||
|
if (!condition) throw new Error(msg || 'assertion failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
T.assertShape = function (obj, schema, label) {
|
||||||
|
var errs = validateShape(obj, schema, label || '$');
|
||||||
|
if (errs.length) throw new Error(errs.join('; '));
|
||||||
|
};
|
||||||
|
|
||||||
|
T.assertArrayOf = function (arr, schema, label) {
|
||||||
|
T.assert(Array.isArray(arr), (label || '$') + ': expected array, got ' + (typeof arr));
|
||||||
|
if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap SDK list response — handles both bare array and paginated envelope.
|
||||||
|
* SDK _unwrap returns { data: [...], total, ... } when pagination metadata
|
||||||
|
* exists, or a bare array when the envelope has only { data: [...] }.
|
||||||
|
*/
|
||||||
|
T.unwrapList = function (r) {
|
||||||
|
if (Array.isArray(r)) return r;
|
||||||
|
if (r && Array.isArray(r.data)) return r.data;
|
||||||
|
throw new Error('expected array or { data: [...] } envelope, got ' + (typeof r));
|
||||||
|
};
|
||||||
|
|
||||||
|
T.assertEnum = function (val, allowed, path) {
|
||||||
|
if (allowed.indexOf(val) === -1)
|
||||||
|
throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Raw Fetch (ICD path) ─────────────────────────────────
|
||||||
|
|
||||||
|
async function _fetchJSON(method, path, body) {
|
||||||
|
var token = await T.getAuthToken();
|
||||||
|
var opts = {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
};
|
||||||
|
if (body !== undefined && method !== 'GET' && method !== 'DELETE')
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
var resp = await fetch(T.base + '/api/v1' + path, opts);
|
||||||
|
if (resp.status === 204) return { _status: 204 };
|
||||||
|
var text = await resp.text();
|
||||||
|
var data;
|
||||||
|
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||||
|
data._status = resp.status;
|
||||||
|
if (!resp.ok) {
|
||||||
|
var err = new Error(method + ' ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : ''));
|
||||||
|
err.status = resp.status;
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
T.raw = {
|
||||||
|
get: function (path) { return _fetchJSON('GET', path); },
|
||||||
|
post: function (path, body) { return _fetchJSON('POST', path, body); },
|
||||||
|
put: function (path, body) { return _fetchJSON('PUT', path, body); },
|
||||||
|
patch: function (path, body) { return _fetchJSON('PATCH', path, body); },
|
||||||
|
del: function (path) { return _fetchJSON('DELETE', path); },
|
||||||
|
};
|
||||||
|
|
||||||
|
T.safeDelete = async function (path) {
|
||||||
|
try { await T.raw.del(path); } catch (e) {
|
||||||
|
console.warn('[SDKR] cleanup failed: DELETE ' + path + ' — ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Auth Token ────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _token = '';
|
||||||
|
|
||||||
|
function _readToken() {
|
||||||
|
var env = (T.base || '').replace(/^\//, '') || 'default';
|
||||||
|
var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token'];
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(keys[i]);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
var p = JSON.parse(raw);
|
||||||
|
if (p) { var t = p.access_token || p.token || p.accessToken || ''; if (t) return t; }
|
||||||
|
} catch (_) { /* not JSON */ }
|
||||||
|
if (raw.length > 20 && raw.indexOf('{') !== 0) return raw;
|
||||||
|
} catch (_) { /* skip */ }
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
T.getAuthToken = async function () {
|
||||||
|
if (!_token) _token = _readToken();
|
||||||
|
return _token;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Cleanup ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.registerCleanup = function (fn) { T.cleanup.push(fn); };
|
||||||
|
|
||||||
|
T.runCleanup = async function () {
|
||||||
|
var fns = T.cleanup.splice(0);
|
||||||
|
for (var i = fns.length - 1; i >= 0; i--) {
|
||||||
|
try { await fns[i](); } catch (_) { /* swallow */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Dual-Path Test ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// sdkFn: async () => result — calls sw.api.*
|
||||||
|
// rawSpec: { method, path, body? } — ICD-correct HTTP call
|
||||||
|
// validate: (result) => void — throws if shape is wrong
|
||||||
|
//
|
||||||
|
// Returns: { verdict, sdkErr?, rawErr?, result? }
|
||||||
|
|
||||||
|
T.dualTest = async function (sdkFn, rawSpec, validate) {
|
||||||
|
// 1. Try SDK
|
||||||
|
var sdkResult, sdkErr;
|
||||||
|
try {
|
||||||
|
sdkResult = await sdkFn();
|
||||||
|
} catch (e) {
|
||||||
|
sdkErr = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sdkErr) {
|
||||||
|
// SDK call succeeded — validate shape
|
||||||
|
try {
|
||||||
|
if (validate) validate(sdkResult);
|
||||||
|
return { verdict: 'PASS', result: sdkResult };
|
||||||
|
} catch (e) {
|
||||||
|
// Shape mismatch — might be _unwrap bug. Confirm with raw.
|
||||||
|
var rawResult2;
|
||||||
|
try {
|
||||||
|
rawResult2 = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body);
|
||||||
|
} catch (_) { /* ignore raw errors in this branch */ }
|
||||||
|
return { verdict: 'SHAPE_BUG', sdkErr: e.message, sdkResult: sdkResult, rawResult: rawResult2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SDK failed — try raw ICD fetch
|
||||||
|
if (!rawSpec) {
|
||||||
|
return { verdict: 'SDK_BUG', sdkErr: sdkErr.message, note: 'no raw spec provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawResult, rawErr;
|
||||||
|
try {
|
||||||
|
rawResult = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body);
|
||||||
|
} catch (e) {
|
||||||
|
rawErr = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawErr) {
|
||||||
|
// Both failed → ICD/backend issue
|
||||||
|
return { verdict: 'ICD_BUG', sdkErr: sdkErr.message, rawErr: rawErr.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw succeeded, SDK failed → SDK has a bug
|
||||||
|
// Optionally validate the raw result too
|
||||||
|
var rawValid = true;
|
||||||
|
if (validate) {
|
||||||
|
try { validate(rawResult); } catch (e) { rawValid = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verdict: 'SDK_BUG',
|
||||||
|
sdkErr: sdkErr.message,
|
||||||
|
rawResult: rawResult,
|
||||||
|
rawValid: rawValid
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Test Harness ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register and run a single test.
|
||||||
|
*
|
||||||
|
* @param {string} domain — e.g. 'channels', 'workflows'
|
||||||
|
* @param {string} group — e.g. 'crud', 'list', 'update'
|
||||||
|
* @param {string} name — human-readable test name
|
||||||
|
* @param {object} spec — { sdk, raw, validate }
|
||||||
|
* sdk: async () => result
|
||||||
|
* raw: { method: 'GET'|'POST'|..., path: '/channels', body?: {...} }
|
||||||
|
* validate: (result) => void (throw on failure)
|
||||||
|
*
|
||||||
|
* For tests that don't use dual-path (e.g. setup/cleanup), pass a
|
||||||
|
* plain async function as the 4th argument instead of a spec object.
|
||||||
|
*/
|
||||||
|
T.test = async function (domain, group, name, specOrFn) {
|
||||||
|
if (T.aborted) return;
|
||||||
|
|
||||||
|
var entry = {
|
||||||
|
domain: domain, group: group, name: name,
|
||||||
|
verdict: null, sdkErr: null, rawErr: null, note: null,
|
||||||
|
durationMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var t0 = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Skip support — mark as SKIP with reason
|
||||||
|
if (typeof specOrFn === 'object' && specOrFn.skip) {
|
||||||
|
entry.verdict = 'SKIP';
|
||||||
|
entry.note = specOrFn.skip;
|
||||||
|
} else if (typeof specOrFn === 'function') {
|
||||||
|
// Simple test — no dual-path
|
||||||
|
await specOrFn();
|
||||||
|
entry.verdict = 'PASS';
|
||||||
|
} else {
|
||||||
|
// Dual-path test
|
||||||
|
var r = await T.dualTest(specOrFn.sdk, specOrFn.raw, specOrFn.validate);
|
||||||
|
entry.verdict = r.verdict;
|
||||||
|
entry.sdkErr = r.sdkErr || null;
|
||||||
|
entry.rawErr = r.rawErr || null;
|
||||||
|
entry.note = r.note || null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
entry.verdict = 'ERROR';
|
||||||
|
entry.sdkErr = e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.durationMs = Math.round(performance.now() - t0);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
var v = entry.verdict.toLowerCase().replace('error', 'icd_bug');
|
||||||
|
if (T.stats.hasOwnProperty(v)) T.stats[v]++;
|
||||||
|
|
||||||
|
T.results.push(entry);
|
||||||
|
|
||||||
|
// Live update
|
||||||
|
if (typeof T.onResult === 'function') T.onResult(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Domain Registry ───────────────────────────────────────
|
||||||
|
|
||||||
|
T.domains = {};
|
||||||
|
|
||||||
|
T.registerDomain = function (name, fn) {
|
||||||
|
T.domains[name] = fn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Run All ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.runAll = async function (domainFilter) {
|
||||||
|
T.running = true;
|
||||||
|
T.aborted = false;
|
||||||
|
T.results = [];
|
||||||
|
T.cleanup = [];
|
||||||
|
T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 };
|
||||||
|
|
||||||
|
if (typeof T.onStart === 'function') T.onStart();
|
||||||
|
|
||||||
|
// Provision fixtures (policies, provider, team) before domains
|
||||||
|
if (typeof T.provisionFixtures === 'function') {
|
||||||
|
try { await T.provisionFixtures(); } catch (e) {
|
||||||
|
console.warn('[SDKR] Fixture provisioning failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var names = Object.keys(T.domains).sort();
|
||||||
|
if (domainFilter) {
|
||||||
|
names = names.filter(function (n) { return domainFilter.indexOf(n) !== -1; });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
if (T.aborted) break;
|
||||||
|
try {
|
||||||
|
await T.domains[names[i]]();
|
||||||
|
} catch (e) {
|
||||||
|
T.results.push({
|
||||||
|
domain: names[i], group: 'setup', name: 'domain crashed',
|
||||||
|
verdict: 'ERROR', sdkErr: e.message, rawErr: null, note: null,
|
||||||
|
durationMs: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Run cleanup between domains
|
||||||
|
await T.runCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardown fixtures (revert policies, etc.)
|
||||||
|
if (typeof T.teardownFixtures === 'function') {
|
||||||
|
try { await T.teardownFixtures(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
T.running = false;
|
||||||
|
if (typeof T.onComplete === 'function') T.onComplete();
|
||||||
|
};
|
||||||
|
|
||||||
|
T.abort = function () { T.aborted = true; };
|
||||||
|
|
||||||
|
})();
|
||||||
107
packages/sdk-test-runner/js/main.js
Normal file
107
packages/sdk-test-runner/js/main.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Entry Point
|
||||||
|
*
|
||||||
|
* Load order:
|
||||||
|
* 1. framework.js — dual-path harness, assertions, raw fetch
|
||||||
|
* 2. shapes.js — ICD response shapes
|
||||||
|
* 3. domains/*.js — per-domain test suites (register on T.domains)
|
||||||
|
* 4. ui.js — render functions
|
||||||
|
* 5. (this file) — boot
|
||||||
|
*/
|
||||||
|
(async function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var mount = document.getElementById('extension-mount');
|
||||||
|
if (!mount) return;
|
||||||
|
|
||||||
|
// ─── Boot SDK ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
var base = window.__BASE__ || '';
|
||||||
|
var ver = window.__VERSION__ || '0';
|
||||||
|
|
||||||
|
if (!window.preact) {
|
||||||
|
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||||
|
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||||
|
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||||
|
window.preact = { h, render };
|
||||||
|
window.hooks = hooksModule;
|
||||||
|
window.html = htmModule.default.bind(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||||
|
await sdk.boot();
|
||||||
|
console.log('[SDKR] SDK booted — window.sw ready');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SDKR] SDK boot failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Namespace ──────────────────────────────────────
|
||||||
|
|
||||||
|
window.SDKR = {
|
||||||
|
mount: mount,
|
||||||
|
manifest: window.__MANIFEST__ || {},
|
||||||
|
base: window.__BASE__ || '',
|
||||||
|
// Populated by framework.js
|
||||||
|
results: [],
|
||||||
|
cleanup: [],
|
||||||
|
stats: {},
|
||||||
|
domains: {},
|
||||||
|
S: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Script Loader ─────────────────────────────────────────
|
||||||
|
|
||||||
|
var surfaceId = window.SDKR.manifest.id || 'sdk-test-runner';
|
||||||
|
var assetBase = '/surfaces/' + surfaceId + '/js/';
|
||||||
|
|
||||||
|
if (window.SDKR.base) {
|
||||||
|
assetBase = window.SDKR.base + assetBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modules = [
|
||||||
|
'framework.js',
|
||||||
|
'shapes.js',
|
||||||
|
'fixtures.js',
|
||||||
|
// Domain test suites
|
||||||
|
'domains/channels.js',
|
||||||
|
'domains/notes.js',
|
||||||
|
'domains/projects.js',
|
||||||
|
'domains/personas.js',
|
||||||
|
'domains/knowledge.js',
|
||||||
|
'domains/workflows.js',
|
||||||
|
'domains/workspaces.js',
|
||||||
|
'domains/tasks.js',
|
||||||
|
'domains/misc.js',
|
||||||
|
'domains/admin.js',
|
||||||
|
// UI (must come last)
|
||||||
|
'ui.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
var loaded = 0;
|
||||||
|
|
||||||
|
function loadNext() {
|
||||||
|
if (loaded >= modules.length) {
|
||||||
|
boot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
|
||||||
|
script.onload = function () { loaded++; loadNext(); };
|
||||||
|
script.onerror = function () {
|
||||||
|
console.error('[SDKR] Failed to load: ' + modules[loaded]);
|
||||||
|
loaded++; loadNext();
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
if (typeof window.SDKR.renderShell === 'function') {
|
||||||
|
window.SDKR.renderShell();
|
||||||
|
} else {
|
||||||
|
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK Test Runner failed to load modules. Check console.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNext();
|
||||||
|
})();
|
||||||
112
packages/sdk-test-runner/js/shapes.js
Normal file
112
packages/sdk-test-runner/js/shapes.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — ICD Response Shapes
|
||||||
|
*
|
||||||
|
* Mirrors the shapes from the ICD test runner. Each shape is a map
|
||||||
|
* of field names to type strings. Types:
|
||||||
|
* string, number, bool, array, object, uuid
|
||||||
|
* string? / number? / object? = nullable/optional
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.S = {};
|
||||||
|
|
||||||
|
// ── Core ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.S.channel = {
|
||||||
|
id: 'string', title: 'string', type: 'string',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.channelFull = {
|
||||||
|
id: 'string', user_id: 'string', title: 'string', type: 'string',
|
||||||
|
ai_mode: 'string?', topic: 'string?', description: 'string?',
|
||||||
|
model: 'string?', provider_config_id: 'string?',
|
||||||
|
system_prompt: 'string?', is_archived: 'bool', is_pinned: 'bool',
|
||||||
|
folder: 'string?', folder_id: 'string?', project_id: 'string?',
|
||||||
|
workspace_id: 'string?',
|
||||||
|
tags: 'array', created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.message = {
|
||||||
|
id: 'string', channel_id: 'string', role: 'string',
|
||||||
|
content: 'string', created_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.persona = {
|
||||||
|
id: 'string', name: 'string', scope: 'string',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.note = {
|
||||||
|
id: 'string', title: 'string',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.project = {
|
||||||
|
id: 'string', name: 'string', scope: 'string', owner_id: 'string',
|
||||||
|
is_archived: 'bool', created_at: 'string', updated_at: 'string',
|
||||||
|
description: 'string?', color: 'string?', icon: 'string?',
|
||||||
|
team_id: 'string?', workspace_id: 'string?', settings: 'object?',
|
||||||
|
channel_count: 'number?', kb_count: 'number?', note_count: 'number?'
|
||||||
|
};
|
||||||
|
T.S.kb = {
|
||||||
|
id: 'string', name: 'string', scope: 'string',
|
||||||
|
embedding_config: 'object', document_count: 'number',
|
||||||
|
chunk_count: 'number', total_bytes: 'number', status: 'string',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.workspace = {
|
||||||
|
id: 'string', name: 'string', owner_type: 'string',
|
||||||
|
owner_id: 'string', status: 'string',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.folder = { id: 'string', name: 'string', created_at: 'string' };
|
||||||
|
T.S.notification = {
|
||||||
|
id: 'string', type: 'string', title: 'string',
|
||||||
|
read: 'bool', created_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.memory = {
|
||||||
|
id: 'string', scope: 'string', owner_id: 'string',
|
||||||
|
key: 'string', value: 'string', confidence: 'number',
|
||||||
|
status: 'string', created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.profile = {
|
||||||
|
id: 'string', username: 'string', email: 'string',
|
||||||
|
role: 'string', settings: 'object', created_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.task = {
|
||||||
|
id: 'string', name: 'string', task_type: 'string',
|
||||||
|
schedule: 'string', is_active: 'bool', created_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.workflow = {
|
||||||
|
id: 'string', name: 'string', slug: 'string',
|
||||||
|
entry_mode: 'string', is_active: 'bool',
|
||||||
|
created_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.workflowStage = {
|
||||||
|
id: 'string', workflow_id: 'string', ordinal: 'number',
|
||||||
|
name: 'string', history_mode: 'string', created_at: 'string',
|
||||||
|
surface_pkg_id: 'string?'
|
||||||
|
};
|
||||||
|
T.S.team = { id: 'string', name: 'string', created_at: 'string?' };
|
||||||
|
T.S.teamMember = { user_id: 'string', role: 'string' };
|
||||||
|
T.S.group = { id: 'string', name: 'string' };
|
||||||
|
T.S.surface = { id: 'string', title: 'string', source: 'string', enabled: 'bool' };
|
||||||
|
T.S.providerConfig = {
|
||||||
|
id: 'string', name: 'string', provider: 'string', scope: 'string'
|
||||||
|
};
|
||||||
|
T.S.extension = {
|
||||||
|
id: 'string', title: 'string', type: 'string', version: 'string',
|
||||||
|
tier: 'string', enabled: 'bool', is_system: 'bool', scope: 'string',
|
||||||
|
source: 'string', installed_at: 'string', updated_at: 'string'
|
||||||
|
};
|
||||||
|
T.S.modelEnabled = {
|
||||||
|
id: 'string', model_id: 'string', display_name: 'string',
|
||||||
|
model_type: 'string', source: 'string',
|
||||||
|
provider_config_id: 'string', provider_name: 'string',
|
||||||
|
provider_type: 'string', capabilities: 'object',
|
||||||
|
scope: 'string', is_persona: 'bool', hidden: 'bool'
|
||||||
|
};
|
||||||
|
T.S.gitCredSummary = {
|
||||||
|
id: 'string', name: 'string', auth_type: 'string', created_at: 'string'
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
262
packages/sdk-test-runner/js/ui.js
Normal file
262
packages/sdk-test-runner/js/ui.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — UI
|
||||||
|
*
|
||||||
|
* Renders: summary bar, domain filter, live results table, export.
|
||||||
|
* Follows ICD runner visual conventions.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
// ─── Verdict Colors ────────────────────────────────────────
|
||||||
|
|
||||||
|
var COLORS = {
|
||||||
|
PASS: 'var(--success, #22c55e)',
|
||||||
|
SDK_BUG: 'var(--warning, #f59e0b)',
|
||||||
|
ICD_BUG: 'var(--danger, #ef4444)',
|
||||||
|
SHAPE_BUG: '#a855f7',
|
||||||
|
SKIP: 'var(--text3, #888)',
|
||||||
|
ERROR: 'var(--danger, #ef4444)'
|
||||||
|
};
|
||||||
|
|
||||||
|
var LABELS = {
|
||||||
|
PASS: 'PASS',
|
||||||
|
SDK_BUG: 'SDK BUG',
|
||||||
|
ICD_BUG: 'ICD BUG',
|
||||||
|
SHAPE_BUG: 'SHAPE',
|
||||||
|
SKIP: 'SKIP',
|
||||||
|
ERROR: 'ERROR'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DOM Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function el(tag, attrs, children) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (attrs) Object.keys(attrs).forEach(function (k) {
|
||||||
|
if (k === 'className') e.className = attrs[k];
|
||||||
|
else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(e.style, attrs[k]);
|
||||||
|
else if (k.indexOf('on') === 0) e.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
||||||
|
else e.setAttribute(k, attrs[k]);
|
||||||
|
});
|
||||||
|
if (children) {
|
||||||
|
if (!Array.isArray(children)) children = [children];
|
||||||
|
children.forEach(function (c) {
|
||||||
|
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
|
||||||
|
else if (c) e.appendChild(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s || ''); }
|
||||||
|
|
||||||
|
// ─── Shell ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
T.renderShell = function () {
|
||||||
|
var mount = T.mount;
|
||||||
|
mount.innerHTML = '';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header = el('div', { className: 'sdkr-header' }, [
|
||||||
|
el('h1', {}, 'SDK Test Runner'),
|
||||||
|
el('span', { className: 'sdkr-version' }, 'v' + (T.manifest.version || '?'))
|
||||||
|
]);
|
||||||
|
mount.appendChild(header);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
mount.appendChild(el('p', { className: 'sdkr-desc' },
|
||||||
|
'Dual-path validation: SDK call → raw ICD fetch. ' +
|
||||||
|
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
|
||||||
|
|
||||||
|
// ── Fixtures Panel ──
|
||||||
|
var fixturePanel = el('div', { className: 'sdkr-fixtures' });
|
||||||
|
|
||||||
|
var fixRow = el('div', { className: 'sdkr-fixture-row' });
|
||||||
|
fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:'));
|
||||||
|
|
||||||
|
// Provider dropdown
|
||||||
|
var provSelect = el('select', { className: 'sdkr-select' });
|
||||||
|
provSelect.appendChild(el('option', { value: '' }, '— provider —'));
|
||||||
|
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
|
||||||
|
provSelect.appendChild(el('option', { value: p }, p));
|
||||||
|
});
|
||||||
|
fixRow.appendChild(provSelect);
|
||||||
|
|
||||||
|
// API key input
|
||||||
|
var keyInput = el('input', {
|
||||||
|
type: 'password', className: 'sdkr-input',
|
||||||
|
placeholder: 'API key (optional — enables model tests)'
|
||||||
|
});
|
||||||
|
fixRow.appendChild(keyInput);
|
||||||
|
|
||||||
|
// Fixture status
|
||||||
|
var fixStatus = el('span', { className: 'sdkr-fixture-status' }, '');
|
||||||
|
|
||||||
|
fixRow.appendChild(fixStatus);
|
||||||
|
fixturePanel.appendChild(fixRow);
|
||||||
|
|
||||||
|
var fixNote = el('div', { className: 'sdkr-fixture-note' },
|
||||||
|
'Provide a provider + API key to unlock embedding/model tests. ' +
|
||||||
|
'Without a key, those tests are skipped. Key is never stored.');
|
||||||
|
fixturePanel.appendChild(fixNote);
|
||||||
|
|
||||||
|
mount.appendChild(fixturePanel);
|
||||||
|
|
||||||
|
// ── Fixture wiring: store user input for provisionFixtures to use ──
|
||||||
|
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
|
||||||
|
|
||||||
|
// Controls row
|
||||||
|
var controlRow = el('div', { className: 'sdkr-controls' });
|
||||||
|
|
||||||
|
// Domain filter checkboxes
|
||||||
|
var filterBox = el('div', { className: 'sdkr-filters' });
|
||||||
|
filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: '));
|
||||||
|
var domainNames = Object.keys(T.domains).sort();
|
||||||
|
var domainChecks = {};
|
||||||
|
|
||||||
|
domainNames.forEach(function (name) {
|
||||||
|
var label = el('label', { className: 'sdkr-check' });
|
||||||
|
var cb = el('input', { type: 'checkbox', checked: 'checked' });
|
||||||
|
domainChecks[name] = cb;
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(document.createTextNode(' ' + name));
|
||||||
|
filterBox.appendChild(label);
|
||||||
|
});
|
||||||
|
controlRow.appendChild(filterBox);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
var btnRow = el('div', { className: 'sdkr-btn-row' });
|
||||||
|
var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All');
|
||||||
|
var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
|
||||||
|
abortBtn.style.display = 'none';
|
||||||
|
var exportBtn = el('button', { className: 'sdkr-btn', onClick: onExport }, 'Export JSON');
|
||||||
|
btnRow.appendChild(runBtn);
|
||||||
|
btnRow.appendChild(abortBtn);
|
||||||
|
btnRow.appendChild(exportBtn);
|
||||||
|
controlRow.appendChild(btnRow);
|
||||||
|
mount.appendChild(controlRow);
|
||||||
|
|
||||||
|
// Summary bar
|
||||||
|
var summaryBar = el('div', { className: 'sdkr-summary' });
|
||||||
|
mount.appendChild(summaryBar);
|
||||||
|
|
||||||
|
// Results table
|
||||||
|
var tableWrap = el('div', { className: 'sdkr-table-wrap' });
|
||||||
|
var table = el('table', { className: 'sdkr-table' });
|
||||||
|
var thead = el('thead', {}, [
|
||||||
|
el('tr', {}, [
|
||||||
|
el('th', {}, '#'),
|
||||||
|
el('th', {}, 'Domain'),
|
||||||
|
el('th', {}, 'Group'),
|
||||||
|
el('th', {}, 'Test'),
|
||||||
|
el('th', {}, 'Verdict'),
|
||||||
|
el('th', {}, 'ms'),
|
||||||
|
el('th', {}, 'Detail')
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
table.appendChild(thead);
|
||||||
|
var tbody = el('tbody', {});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
tableWrap.appendChild(table);
|
||||||
|
mount.appendChild(tableWrap);
|
||||||
|
|
||||||
|
// ── Callbacks ──
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
var s = T.stats;
|
||||||
|
var total = T.results.length;
|
||||||
|
summaryBar.innerHTML = '';
|
||||||
|
var items = [
|
||||||
|
['PASS', s.pass],
|
||||||
|
['SDK_BUG', s.sdk_bug],
|
||||||
|
['ICD_BUG', s.icd_bug],
|
||||||
|
['SHAPE_BUG', s.shape_bug],
|
||||||
|
['SKIP', s.skip]
|
||||||
|
];
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var badge = el('span', {
|
||||||
|
className: 'sdkr-badge',
|
||||||
|
style: {
|
||||||
|
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
|
||||||
|
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
|
||||||
|
}
|
||||||
|
}, LABELS[item[0]] + ': ' + item[1]);
|
||||||
|
summaryBar.appendChild(badge);
|
||||||
|
});
|
||||||
|
summaryBar.appendChild(el('span', { className: 'sdkr-badge',
|
||||||
|
style: { background: 'var(--bg2, #333)' }
|
||||||
|
}, 'Total: ' + total));
|
||||||
|
}
|
||||||
|
|
||||||
|
T.onResult = function (entry) {
|
||||||
|
var idx = T.results.length;
|
||||||
|
var v = entry.verdict;
|
||||||
|
var detail = '';
|
||||||
|
if (entry.sdkErr) detail += 'SDK: ' + esc(entry.sdkErr);
|
||||||
|
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
|
||||||
|
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
|
||||||
|
|
||||||
|
var row = el('tr', { className: 'sdkr-row sdkr-row-' + v.toLowerCase() }, [
|
||||||
|
el('td', {}, String(idx)),
|
||||||
|
el('td', {}, esc(entry.domain)),
|
||||||
|
el('td', {}, esc(entry.group)),
|
||||||
|
el('td', { className: 'sdkr-test-name' }, esc(entry.name)),
|
||||||
|
el('td', {}, [
|
||||||
|
el('span', {
|
||||||
|
className: 'sdkr-verdict',
|
||||||
|
style: { color: COLORS[v] || '#fff' }
|
||||||
|
}, LABELS[v] || v)
|
||||||
|
]),
|
||||||
|
el('td', { className: 'sdkr-ms' }, String(entry.durationMs)),
|
||||||
|
el('td', { className: 'sdkr-detail' }, detail)
|
||||||
|
]);
|
||||||
|
tbody.appendChild(row);
|
||||||
|
updateSummary();
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
T.onStart = function () {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
runBtn.disabled = true;
|
||||||
|
runBtn.textContent = 'Running...';
|
||||||
|
abortBtn.style.display = '';
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
T.onComplete = function () {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
runBtn.textContent = 'Run All';
|
||||||
|
abortBtn.style.display = 'none';
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
function onRun() {
|
||||||
|
var selected = [];
|
||||||
|
domainNames.forEach(function (n) {
|
||||||
|
if (domainChecks[n].checked) selected.push(n);
|
||||||
|
});
|
||||||
|
T.runAll(selected.length === domainNames.length ? null : selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExport() {
|
||||||
|
var data = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: T.manifest.version,
|
||||||
|
stats: T.stats,
|
||||||
|
results: T.results
|
||||||
|
};
|
||||||
|
var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'sdk-test-results-' + Date.now() + '.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
9
packages/sdk-test-runner/manifest.json
Normal file
9
packages/sdk-test-runner/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "sdk-test-runner",
|
||||||
|
"title": "SDK Test Runner",
|
||||||
|
"route": "/s/sdk-test-runner",
|
||||||
|
"auth": "authenticated",
|
||||||
|
"layout": "single",
|
||||||
|
"version": "0.37.15.0",
|
||||||
|
"description": "Dual-path SDK validation: tests every sw.api.* domain method against the ICD. SDK fail + ICD pass = SDK bug. Both fail = ICD bug."
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"chat-switchboard/models"
|
"chat-switchboard/models"
|
||||||
"chat-switchboard/storage"
|
"chat-switchboard/storage"
|
||||||
"chat-switchboard/store"
|
"chat-switchboard/store"
|
||||||
|
"chat-switchboard/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Default Limits ─────────────────────────
|
// ── Default Limits ─────────────────────────
|
||||||
@@ -53,6 +55,7 @@ var allowedMIMETypes = map[string]bool{
|
|||||||
type FileHandler struct {
|
type FileHandler struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
objStore storage.ObjectStore
|
objStore storage.ObjectStore
|
||||||
|
wfs *workspace.FS // nil if workspace FS not configured
|
||||||
extQueue *extraction.Queue // nil if extraction disabled
|
extQueue *extraction.Queue // nil if extraction disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,11 @@ func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue
|
|||||||
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWorkspaceFS attaches the workspace filesystem for project file operations.
|
||||||
|
func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) {
|
||||||
|
h.wfs = wfs
|
||||||
|
}
|
||||||
|
|
||||||
// ── Upload ─────────────────────────────────
|
// ── Upload ─────────────────────────────────
|
||||||
// POST /api/v1/channels/:id/files
|
// POST /api/v1/channels/:id/files
|
||||||
// Multipart form: file field "file", returns file metadata.
|
// Multipart form: file field "file", returns file metadata.
|
||||||
@@ -496,27 +504,92 @@ func sanitizeFilename(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Project Files (v0.22.4) ────────────────
|
// ── Project Files (v0.22.4, reworked v0.37.17) ─────────────
|
||||||
|
// Project files route through the Workspace FS subsystem.
|
||||||
|
// A workspace is auto-created on first file upload (lazy binding).
|
||||||
|
|
||||||
// UploadToProject handles project-scoped file uploads.
|
// verifyProjectAccess checks that the user can access the project.
|
||||||
|
// Returns true if access is granted (admins bypass).
|
||||||
|
func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool {
|
||||||
|
if c.GetString("role") == "admin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
|
||||||
|
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
|
||||||
|
if err != nil || !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureProjectWorkspace returns the project's workspace, creating one if needed.
|
||||||
|
func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) {
|
||||||
|
proj, err := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("project not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already has a workspace — load and return it.
|
||||||
|
if proj.WorkspaceID != nil && *proj.WorkspaceID != "" {
|
||||||
|
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err)
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workspace for this project.
|
||||||
|
ws := &models.Workspace{
|
||||||
|
OwnerType: models.WorkspaceOwnerProject,
|
||||||
|
OwnerID: projectID,
|
||||||
|
Name: proj.Name + " Files",
|
||||||
|
Status: models.WorkspaceStatusActive,
|
||||||
|
}
|
||||||
|
ws.ID = store.NewID()
|
||||||
|
ws.RootPath = "workspaces/" + ws.ID
|
||||||
|
|
||||||
|
if err := h.stores.Workspaces.Create(ctx, ws); err != nil {
|
||||||
|
// Race guard: another request may have created it. Re-read the project.
|
||||||
|
proj2, err2 := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" {
|
||||||
|
return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to create workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory on disk.
|
||||||
|
if err := h.wfs.CreateDir(ws); err != nil {
|
||||||
|
log.Printf("warning: workspace mkdir for project %s: %v", projectID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind workspace to project.
|
||||||
|
h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID})
|
||||||
|
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadToProject handles project-scoped file uploads via workspace FS.
|
||||||
// POST /api/v1/projects/:id/files
|
// POST /api/v1/projects/:id/files
|
||||||
func (h *FileHandler) UploadToProject(c *gin.Context) {
|
func (h *FileHandler) UploadToProject(c *gin.Context) {
|
||||||
if h.objStore == nil {
|
if h.wfs == nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
projectID := c.Param("id")
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Verify project access (user owns or is team member; admins bypass)
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
if c.GetString("role") != "admin" {
|
return
|
||||||
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
|
}
|
||||||
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
|
|
||||||
if err != nil || !ok {
|
ws, err := h.ensureProjectWorkspace(ctx, projectID)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
if err != nil {
|
||||||
return
|
log.Printf("error: ensure workspace for project %s: %v", projectID, err)
|
||||||
}
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, header, err := c.Request.FormFile("file")
|
file, header, err := c.Request.FormFile("file")
|
||||||
@@ -531,6 +604,19 @@ func (h *FileHandler) UploadToProject(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quota check
|
||||||
|
stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID)
|
||||||
|
quota := workspace.DefaultMaxBytes
|
||||||
|
if ws.MaxBytes != nil {
|
||||||
|
quota = *ws.MaxBytes
|
||||||
|
}
|
||||||
|
if stats != nil && stats.TotalBytes+header.Size > quota {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||||
|
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Detect content type
|
// Detect content type
|
||||||
buf := make([]byte, 512)
|
buf := make([]byte, 512)
|
||||||
n, _ := file.Read(buf)
|
n, _ := file.Read(buf)
|
||||||
@@ -542,70 +628,331 @@ func (h *FileHandler) UploadToProject(c *gin.Context) {
|
|||||||
contentType = better
|
contentType = better
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store file
|
filename := sanitizeFilename(header.Filename)
|
||||||
storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename))
|
|
||||||
if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil {
|
// Determine upload path (support optional path prefix via query param)
|
||||||
log.Printf("error: project file upload: %v", err)
|
uploadPath := filename
|
||||||
|
if prefix := c.Query("path"); prefix != "" {
|
||||||
|
uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil {
|
||||||
|
log.Printf("error: project file write: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
att := models.File{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
ChannelID: "", // no channel association
|
"path": uploadPath,
|
||||||
UserID: userID,
|
"filename": filename,
|
||||||
ProjectID: &projectID,
|
"content_type": contentType,
|
||||||
Origin: models.FileOriginUserUpload,
|
"size_bytes": header.Size,
|
||||||
Filename: header.Filename,
|
})
|
||||||
ContentType: contentType,
|
|
||||||
SizeBytes: header.Size,
|
|
||||||
StorageKey: storageKey,
|
|
||||||
DisplayHint: displayHintFor(contentType),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.stores.Files.Create(c.Request.Context(), &att); err != nil {
|
|
||||||
log.Printf("error: project file create: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.extQueue != nil {
|
|
||||||
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
|
||||||
log.Printf("warning: project file extraction enqueue failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, att)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByProject returns all files uploaded to a project.
|
// ListByProject returns workspace files for a project.
|
||||||
// GET /api/v1/projects/:id/files
|
// GET /api/v1/projects/:id/files
|
||||||
func (h *FileHandler) ListByProject(c *gin.Context) {
|
func (h *FileHandler) ListByProject(c *gin.Context) {
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
projectID := c.Param("id")
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Admins bypass project ownership/membership checks
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
if c.GetString("role") != "admin" {
|
return
|
||||||
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
|
|
||||||
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
|
|
||||||
if err != nil || !ok {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID)
|
// Load project to get workspace_id
|
||||||
|
proj, err := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No workspace yet → empty list
|
||||||
|
if proj.WorkspaceID == nil || *proj.WorkspaceID == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirPath := c.DefaultQuery("path", "")
|
||||||
|
recursive := c.DefaultQuery("recursive", "true") == "true"
|
||||||
|
|
||||||
|
files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error: list project files: %v", err)
|
log.Printf("error: list project files: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if files == nil {
|
if files == nil {
|
||||||
files = []models.File{}
|
files = []models.WorkspaceFile{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
|
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadProjectFile streams a single file from the project workspace.
|
||||||
|
// GET /api/v1/projects/:id/files/download?path=...
|
||||||
|
func (h *FileHandler) DownloadProjectFile(c *gin.Context) {
|
||||||
|
if h.wfs == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := c.Query("path")
|
||||||
|
if filePath == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proj, err := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err != nil || proj.WorkspaceID == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, size, err := h.wfs.ReadFile(ctx, ws, filePath)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Detect content type
|
||||||
|
f, _ := h.wfs.Stat(ctx, ws, filePath)
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if f != nil && f.ContentType != "" {
|
||||||
|
contentType = f.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Base(filePath)
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
||||||
|
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProjectFile deletes a file or directory from the project workspace.
|
||||||
|
// DELETE /api/v1/projects/:id/files?path=...
|
||||||
|
func (h *FileHandler) DeleteProjectFile(c *gin.Context) {
|
||||||
|
if h.wfs == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := c.Query("path")
|
||||||
|
if filePath == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proj, err := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err != nil || proj.WorkspaceID == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recursive := c.Query("recursive") == "true"
|
||||||
|
if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MkdirProject creates a directory in the project workspace.
|
||||||
|
// POST /api/v1/projects/:id/files/mkdir
|
||||||
|
func (h *FileHandler) MkdirProject(c *gin.Context) {
|
||||||
|
if h.wfs == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.ensureProjectWorkspace(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept path from query param or JSON body
|
||||||
|
dirPath := c.Query("path")
|
||||||
|
if dirPath == "" {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&body) == nil && body.Path != "" {
|
||||||
|
dirPath = body.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dirPath == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace.
|
||||||
|
// POST /api/v1/projects/:id/archive/upload
|
||||||
|
func (h *FileHandler) UploadProjectArchive(c *gin.Context) {
|
||||||
|
if h.wfs == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.ensureProjectWorkspace(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
format := detectArchiveFormat(header.Filename)
|
||||||
|
if format == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp file (zip extraction needs seekable file)
|
||||||
|
tmp, err := os.CreateTemp("", "project-archive-*")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmp, file); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
count, err := h.wfs.ExtractArchive(ctx, ws, tmpName, format)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "files_extracted": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadProjectArchive creates and streams a zip/tar.gz of all project files.
|
||||||
|
// GET /api/v1/projects/:id/archive/download?format=zip
|
||||||
|
func (h *FileHandler) DownloadProjectArchive(c *gin.Context) {
|
||||||
|
if h.wfs == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
projectID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !h.verifyProjectAccess(c, projectID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proj, err := h.stores.Projects.GetByID(ctx, projectID)
|
||||||
|
if err != nil || proj.WorkspaceID == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := c.DefaultQuery("format", "zip")
|
||||||
|
if format != "zip" && format != "tar.gz" && format != "tgz" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, err := h.wfs.CreateArchive(ctx, ws, format)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(archivePath)
|
||||||
|
|
||||||
|
ext := "zip"
|
||||||
|
if format == "tar.gz" || format == "tgz" {
|
||||||
|
ext = "tar.gz"
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.%s", proj.Name, ext)
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
c.File(archivePath)
|
||||||
|
}
|
||||||
|
|
||||||
// extToMIME maps file extensions to MIME types for cases where
|
// extToMIME maps file extensions to MIME types for cases where
|
||||||
// http.DetectContentType returns application/octet-stream.
|
// http.DetectContentType returns application/octet-stream.
|
||||||
var extToMIME = map[string]string{
|
var extToMIME = map[string]string{
|
||||||
|
|||||||
@@ -888,14 +888,18 @@ func main() {
|
|||||||
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
|
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
|
||||||
protected.GET("/projects/:id/notes", projectH.ListNotes)
|
protected.GET("/projects/:id/notes", projectH.ListNotes)
|
||||||
|
|
||||||
// Project files (v0.22.4) — wired via fileH which is declared later,
|
// Project files (v0.22.4, reworked v0.37.17 — workspace-backed)
|
||||||
// so we use a closure that captures the variable.
|
projFileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||||
protected.POST("/projects/:id/files", func(c *gin.Context) {
|
if wfs != nil {
|
||||||
handlers.NewFileHandler(stores, objStore, extQueue).UploadToProject(c)
|
projFileH.SetWorkspaceFS(wfs)
|
||||||
})
|
}
|
||||||
protected.GET("/projects/:id/files", func(c *gin.Context) {
|
protected.POST("/projects/:id/files", projFileH.UploadToProject)
|
||||||
handlers.NewFileHandler(stores, objStore, extQueue).ListByProject(c)
|
protected.GET("/projects/:id/files", projFileH.ListByProject)
|
||||||
})
|
protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
|
||||||
|
protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile)
|
||||||
|
protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject)
|
||||||
|
protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive)
|
||||||
|
protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive)
|
||||||
|
|
||||||
// Notifications (v0.20.0)
|
// Notifications (v0.20.0)
|
||||||
notifH := handlers.NewNotificationHandler(stores, hub)
|
notifH := handlers.NewNotificationHandler(stores, hub)
|
||||||
|
|||||||
@@ -4142,11 +4142,23 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Projects
|
- Projects
|
||||||
- Files
|
- Files
|
||||||
summary: List project files
|
summary: List project files (workspace-backed)
|
||||||
|
description: Returns workspace files for the project. Auto-creates workspace on first upload.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/ResourceID'
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
- name: path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Directory path to list (empty = root)
|
||||||
|
- name: recursive
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
default: 'true'
|
||||||
|
description: Include subdirectories
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Project files
|
description: Project files
|
||||||
@@ -4155,10 +4167,23 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
data:
|
files:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Project'
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
is_directory:
|
||||||
|
type: boolean
|
||||||
|
content_type:
|
||||||
|
type: string
|
||||||
|
size_bytes:
|
||||||
|
type: integer
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'404':
|
'404':
|
||||||
@@ -4167,11 +4192,17 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Projects
|
- Projects
|
||||||
- Files
|
- Files
|
||||||
summary: Upload file to project
|
summary: Upload file to project workspace
|
||||||
|
description: Uploads a file to the project's workspace. Auto-creates workspace on first upload.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/ResourceID'
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
- name: path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Subdirectory path to upload into
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
multipart/form-data:
|
multipart/form-data:
|
||||||
@@ -4187,17 +4218,164 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Project'
|
type: object
|
||||||
'400':
|
properties:
|
||||||
$ref: '#/components/responses/BadRequest'
|
path:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
content_type:
|
||||||
|
type: string
|
||||||
|
size_bytes:
|
||||||
|
type: integer
|
||||||
|
'413':
|
||||||
|
description: File too large or workspace quota exceeded
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
- Files
|
||||||
|
summary: Delete project file
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
- name: path
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: recursive
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: File deleted
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MessageResponse'
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
/api/v1/projects/{id}/files/download:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
- Files
|
||||||
|
summary: Download project file
|
||||||
|
description: Streams raw file content with Content-Disposition attachment header.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
- name: path
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: File content
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/api/v1/projects/{id}/files/mkdir:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
- Files
|
||||||
|
summary: Create directory in project workspace
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- path
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Directory created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MessageResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
/api/v1/projects/{id}/archive/upload:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
- Files
|
||||||
|
summary: Upload and extract archive into project workspace
|
||||||
|
description: Accepts .zip or .tar.gz, extracts contents into workspace root.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Archive extracted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
files_extracted:
|
||||||
|
type: integer
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
/api/v1/projects/{id}/archive/download:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Projects
|
||||||
|
- Files
|
||||||
|
summary: Download all project files as archive
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
- name: format
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [zip, tar.gz, tgz]
|
||||||
|
default: zip
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Archive download
|
||||||
|
content:
|
||||||
|
application/zip:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
/api/v1/workspaces:
|
/api/v1/workspaces:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -647,6 +647,128 @@
|
|||||||
border-color: var(--text, #e8e8ed);
|
border-color: var(--text, #e8e8ed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── File Browser (v0.37.17) ──────────────── */
|
||||||
|
|
||||||
|
.sw-projects__file-browser {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-actions-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
border-bottom: 1px solid var(--border, #2e2e35);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border, #2e2e35);
|
||||||
|
color: var(--text-3, #999);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sw-projects__file-action-btn:hover {
|
||||||
|
color: var(--text-1, #fff);
|
||||||
|
border-color: var(--text-3, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: default;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.sw-projects__file-row:hover {
|
||||||
|
background: var(--bg-3, rgba(255,255,255,0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-chevron {
|
||||||
|
font-size: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-3, #999);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sw-projects__file-chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2, #ccc);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sw-projects__file-name:hover {
|
||||||
|
color: var(--text-1, #fff);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3, #999);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__file-delete {
|
||||||
|
opacity: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.sw-projects__file-row:hover .sw-projects__file-delete {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.sw-projects__file-delete:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-projects__dropzone {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px dashed var(--border, #2e2e35);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.sw-projects__dropzone:hover {
|
||||||
|
border-color: var(--text-3, #999);
|
||||||
|
color: var(--text-2, #ccc);
|
||||||
|
}
|
||||||
|
.sw-projects__dropzone--active {
|
||||||
|
border-color: var(--accent, #4f8cff);
|
||||||
|
color: var(--accent, #4f8cff);
|
||||||
|
background: rgba(79, 140, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────── */
|
/* ── Responsive ──────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -140,13 +140,17 @@ export function createDomains(restClient) {
|
|||||||
notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
|
notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
|
||||||
addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
|
addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
|
||||||
removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
|
removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
|
||||||
files: (id) => rc.get(`/api/v1/projects/${id}/files`),
|
files: (id, opts) => rc.get(`/api/v1/projects/${id}/files` + _qs(opts)),
|
||||||
uploadFile: (id, file) => rc.upload(`/api/v1/projects/${id}/files`, file),
|
uploadFile: (id, file, path) => rc.upload(`/api/v1/projects/${id}/files` + (path ? _qs({ path }) : ''), file),
|
||||||
|
deleteFile: (id, path) => rc.del(`/api/v1/projects/${id}/files` + _qs({ path })),
|
||||||
|
mkdir: (id, path) => rc.post(`/api/v1/projects/${id}/files/mkdir`, { path }),
|
||||||
|
uploadArchive: (id, file) => rc.upload(`/api/v1/projects/${id}/archive/upload`, file),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 7. Workspaces ──────────────────────
|
// ── 7. Workspaces ──────────────────────
|
||||||
workspaces: {
|
workspaces: {
|
||||||
...crud(rc, '/api/v1/workspaces'),
|
...crud(rc, '/api/v1/workspaces'),
|
||||||
|
update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
|
||||||
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
|
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
|
||||||
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
|
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
|
||||||
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
|
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
|
||||||
@@ -187,11 +191,12 @@ export function createDomains(restClient) {
|
|||||||
notifications: {
|
notifications: {
|
||||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
||||||
markRead: (id) => rc.post(`/api/v1/notifications/${id}/read`, {}),
|
markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`, {}),
|
||||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read', {}),
|
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read', {}),
|
||||||
prefs: () => rc.get('/api/v1/notifications/preferences'),
|
prefs: () => rc.get('/api/v1/notifications/preferences'),
|
||||||
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
|
setPref: (type, data) => rc.put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data),
|
||||||
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
|
delPref: (type) => rc.del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`),
|
||||||
|
del: (id) => rc.del(`/api/v1/notifications/${id}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 12. Extensions ─────────────────────
|
// ── 12. Extensions ─────────────────────
|
||||||
@@ -272,6 +277,8 @@ export function createDomains(restClient) {
|
|||||||
// ── 15. Workflows ──────────────────────
|
// ── 15. Workflows ──────────────────────
|
||||||
workflows: {
|
workflows: {
|
||||||
...crud(rc, '/api/v1/workflows'),
|
...crud(rc, '/api/v1/workflows'),
|
||||||
|
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
|
||||||
|
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
|
||||||
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
|
||||||
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
|
||||||
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
|
||||||
@@ -294,8 +301,8 @@ export function createDomains(restClient) {
|
|||||||
tasks: {
|
tasks: {
|
||||||
...crud(rc, '/api/v1/tasks'),
|
...crud(rc, '/api/v1/tasks'),
|
||||||
runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
|
runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
|
||||||
start: (id, data) => rc.post(`/api/v1/tasks/${id}/start`, data || {}),
|
start: (id, data) => rc.post(`/api/v1/tasks/${id}/run`, data || {}),
|
||||||
stop: (id) => rc.post(`/api/v1/tasks/${id}/stop`, {}),
|
stop: (id) => rc.post(`/api/v1/tasks/${id}/kill`, {}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 17. Surfaces ───────────────────────
|
// ── 17. Surfaces ───────────────────────
|
||||||
@@ -522,13 +529,9 @@ export function createDomains(restClient) {
|
|||||||
del: (id) => rc.del(`/api/v1/folders/${id}`),
|
del: (id) => rc.del(`/api/v1/folders/${id}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
notifications: {
|
// NOTE: notifications is defined in the main domain block above (§11).
|
||||||
list: (params) => rc.get('/api/v1/notifications', params),
|
// The duplicate here was overwriting prefs/setPref/delPref methods.
|
||||||
unreadCount: () => rc.get('/api/v1/notifications/unread-count'),
|
// Removed — see line ~190 for the canonical definition.
|
||||||
markRead: (id) => rc.patch(`/api/v1/notifications/${id}/read`),
|
|
||||||
markAllRead: () => rc.post('/api/v1/notifications/mark-all-read'),
|
|
||||||
del: (id) => rc.del(`/api/v1/notifications/${id}`),
|
|
||||||
},
|
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
get: (id) => rc.get(`/api/v1/files/${id}`),
|
get: (id) => rc.get(`/api/v1/files/${id}`),
|
||||||
|
|||||||
@@ -205,14 +205,35 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
|||||||
catch (e) { sw.toast(e.message, 'error'); }
|
catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File state ──
|
||||||
|
const [expandedDirs, setExpandedDirs] = useState({});
|
||||||
|
const [uploading, setUploading] = useState('');
|
||||||
|
|
||||||
// ── File upload ──
|
// ── File upload ──
|
||||||
|
const ARCHIVE_EXTS = ['.zip', '.tar.gz', '.tgz'];
|
||||||
|
|
||||||
async function uploadFiles(files) {
|
async function uploadFiles(files) {
|
||||||
|
const total = files.length;
|
||||||
|
let done = 0;
|
||||||
|
setUploading(`Uploading 0 of ${total}\u2026`);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
await sw.api.projects.uploadFile(project.id, file);
|
const isArchive = ARCHIVE_EXTS.some(e => file.name.toLowerCase().endsWith(e));
|
||||||
sw.toast(`Uploaded ${file.name}`, 'success');
|
if (isArchive && await sw.confirm(`Extract "${file.name}" contents into the project?`)) {
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
const r = await sw.api.projects.uploadArchive(project.id, file);
|
||||||
|
sw.toast(`Extracted ${r?.files_extracted || 0} files`, 'success');
|
||||||
|
} else {
|
||||||
|
await sw.api.projects.uploadFile(project.id, file);
|
||||||
|
sw.toast(`Uploaded ${file.name}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.status === 413 ? 'Storage quota exceeded' : e.message;
|
||||||
|
sw.toast(`${file.name}: ${msg}`, 'error');
|
||||||
|
}
|
||||||
|
done++;
|
||||||
|
setUploading(`Uploading ${done} of ${total}\u2026`);
|
||||||
}
|
}
|
||||||
|
setUploading('');
|
||||||
loadAssoc();
|
loadAssoc();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +251,127 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
|||||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File actions ──
|
||||||
|
async function downloadFile(path) {
|
||||||
|
try {
|
||||||
|
const token = sw.auth?.token?.();
|
||||||
|
const base = window.__BASE__ || '';
|
||||||
|
const url = `${base}/api/v1/projects/${project.id}/files/download?path=${encodeURIComponent(path)}`;
|
||||||
|
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||||
|
if (!res.ok) throw new Error('Download failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = path.split('/').pop() || 'download';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(path) {
|
||||||
|
const name = path.split('/').pop() || path;
|
||||||
|
if (!await sw.confirm(`Delete "${name}"?`, true)) return;
|
||||||
|
try {
|
||||||
|
await sw.api.projects.deleteFile(project.id, path);
|
||||||
|
sw.toast('Deleted', 'success');
|
||||||
|
loadAssoc();
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFolder() {
|
||||||
|
const name = await sw.prompt('Folder name:');
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await sw.api.projects.mkdir(project.id, name);
|
||||||
|
sw.toast(`Created "${name}"`, 'success');
|
||||||
|
loadAssoc();
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAll() {
|
||||||
|
try {
|
||||||
|
const token = sw.auth?.token?.();
|
||||||
|
const base = window.__BASE__ || '';
|
||||||
|
const url = `${base}/api/v1/projects/${project.id}/archive/download?format=zip`;
|
||||||
|
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||||
|
if (!res.ok) throw new Error('Download failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${project.name || 'project'}.zip`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File tree builder ──
|
||||||
|
function buildTree(files) {
|
||||||
|
if (!files || !files.length) return [];
|
||||||
|
// Sort: directories first, then alpha
|
||||||
|
const sorted = [...files].sort((a, b) => {
|
||||||
|
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||||
|
return (a.path || '').localeCompare(b.path || '');
|
||||||
|
});
|
||||||
|
// Group into top-level entries (split by first path segment)
|
||||||
|
const root = [];
|
||||||
|
const dirMap = {};
|
||||||
|
for (const f of sorted) {
|
||||||
|
const parts = (f.path || f.filename || '').split('/').filter(Boolean);
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
root.push({ ...f, name: parts[0] || f.filename, children: f.is_directory ? [] : null, depth: 0 });
|
||||||
|
if (f.is_directory) dirMap[f.path] = root[root.length - 1];
|
||||||
|
} else {
|
||||||
|
// Find parent dir in root
|
||||||
|
const parentPath = parts.slice(0, -1).join('/');
|
||||||
|
const parent = dirMap[parentPath];
|
||||||
|
const entry = { ...f, name: parts[parts.length - 1], children: f.is_directory ? [] : null, depth: parts.length - 1 };
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(entry);
|
||||||
|
} else {
|
||||||
|
root.push(entry);
|
||||||
|
}
|
||||||
|
if (f.is_directory) dirMap[f.path] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(f) {
|
||||||
|
if (f.is_directory) return '\uD83D\uDCC1';
|
||||||
|
const ct = f.content_type || '';
|
||||||
|
if (ct.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
|
if (ct === 'application/pdf') return '\uD83D\uDCC4';
|
||||||
|
if (ct.startsWith('text/')) return '\uD83D\uDCC3';
|
||||||
|
return '\uD83D\uDCCE';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileTree(entries) {
|
||||||
|
if (!entries || !entries.length) return null;
|
||||||
|
return entries.map(f => {
|
||||||
|
const isDir = f.is_directory;
|
||||||
|
const expanded = expandedDirs[f.path];
|
||||||
|
const indent = (f.depth || 0) * 16;
|
||||||
|
return html`<div key=${f.path || f.name}>
|
||||||
|
<div class="sw-projects__file-row" style=${'padding-left:' + (8 + indent) + 'px'}>
|
||||||
|
${isDir && html`<span class=${'sw-projects__file-chevron' + (expanded ? ' sw-projects__file-chevron--open' : '')}
|
||||||
|
onClick=${() => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6</span>`}
|
||||||
|
<span class="sw-projects__file-icon">${fileIcon(f)}</span>
|
||||||
|
<span class="sw-projects__file-name"
|
||||||
|
onClick=${isDir
|
||||||
|
? () => setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))
|
||||||
|
: () => downloadFile(f.path)}
|
||||||
|
title=${isDir ? 'Toggle folder' : 'Click to download'}>
|
||||||
|
${f.name}
|
||||||
|
</span>
|
||||||
|
${!isDir && html`<span class="sw-projects__file-meta">${formatBytes(f.size_bytes)}</span>`}
|
||||||
|
${isDir && f.children && html`<span class="sw-projects__file-meta">${f.children.length} items</span>`}
|
||||||
|
<button class="sw-projects__file-delete" onClick=${() => deleteFile(f.path)} title="Delete">\u00D7</button>
|
||||||
|
</div>
|
||||||
|
${isDir && expanded && f.children && renderFileTree(f.children)}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── New conversation ──
|
// ── New conversation ──
|
||||||
async function newConversation() {
|
async function newConversation() {
|
||||||
try {
|
try {
|
||||||
@@ -356,19 +498,28 @@ export function ProjectDetail({ project, onBack, onUpdate }) {
|
|||||||
${renderPicker('notes', 'No notes available.')}
|
${renderPicker('notes', 'No notes available.')}
|
||||||
`, () => openPicker('notes'))}
|
`, () => openPicker('notes'))}
|
||||||
|
|
||||||
${renderSection('files', 'Files', assoc.files.length, html`
|
${renderSection('files', 'Files', assoc.files.filter(f => !f.is_directory).length, html`
|
||||||
${assoc.files.map(f => html`
|
<div class="sw-projects__file-browser">
|
||||||
<div key=${f.id} class="sw-projects__section-item">
|
${assoc.files.length > 0 && html`
|
||||||
<span class="sw-projects__section-item-name">${f.filename || f.name}</span>
|
<div class="sw-projects__file-actions-bar">
|
||||||
<span class="sw-projects__section-item-meta">${formatBytes(f.size_bytes || f.size)}</span>
|
<button class="sw-projects__file-action-btn" onClick=${createFolder}
|
||||||
</div>
|
title="New folder">\uD83D\uDCC1 New folder</button>
|
||||||
`)}
|
<button class="sw-projects__file-action-btn" onClick=${downloadAll}
|
||||||
|
title="Download all as ZIP">\u2B07 Download all</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${renderFileTree(buildTree(assoc.files))}
|
||||||
|
</div>
|
||||||
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
<div class=${'sw-projects__dropzone' + (dragActive ? ' sw-projects__dropzone--active' : '')}
|
||||||
onClick=${handleUploadClick}
|
onClick=${handleUploadClick}
|
||||||
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
onDragOver=${e => { e.preventDefault(); setDragActive(true); }}
|
||||||
onDragLeave=${() => setDragActive(false)}
|
onDragLeave=${() => setDragActive(false)}
|
||||||
onDrop=${handleDrop}>
|
onDrop=${handleDrop}>
|
||||||
${dragActive ? 'Drop files here' : 'Add PDFs, documents, or other text to reference in this project.'}
|
${uploading ? uploading
|
||||||
|
: dragActive ? 'Drop files here'
|
||||||
|
: assoc.files.length === 0
|
||||||
|
? 'Add PDFs, documents, or other text to reference in this project.'
|
||||||
|
: 'Drop files or click to upload more'}
|
||||||
</div>
|
</div>
|
||||||
`, handleUploadClick)}
|
`, handleUploadClick)}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user