Skip to content
123 changes: 122 additions & 1 deletion apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { HTTPException } from "hono/http-exception";

import {
addApprovalCommentRequestSchema,
createApprovalRequestSchema,
delegateApprovalRequestSchema,
permissionPolicyWriteSchema,
respondApprovalRequestSchema,
useEvidenceForTaskRequestSchema
} from "@workhub/contracts";

import app from "./app.js";
import { httpErrorCodeFor } from "./http-error-codes.js";
import { jsonObjectMessage, malformedJsonMessage } from "./routes/json-body.js";
Expand All @@ -20,6 +29,10 @@ interface ErrorBody {
};
}

type ZodRequestObject = {
shape: Record<string, { isOptional: () => boolean }>;
};

const documentedMethods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]);
const runtimeContractRouteIgnores = new Set(["/", "/openapi.json", "/api/openapi.json"]);

Expand Down Expand Up @@ -121,6 +134,49 @@ function jsonErrorCodeProperty(
return error?.properties?.code;
}

function assertJsonErrorCodes(
paths: Record<string, Record<string, unknown>>,
path: string,
method: string,
status: string,
codes: string[]
) {
assert.deepEqual(jsonErrorCodeProperty(paths, path, method, status), {
type: "string",
enum: codes
}, `${method.toUpperCase()} ${path} ${status} error codes drifted`);
}

function zodPropertyNames(schema: ZodRequestObject) {
return Object.keys(schema.shape).sort();
}

function zodRequiredPropertyNames(schema: ZodRequestObject) {
return Object.entries(schema.shape)
.filter(([, field]) => !field.isOptional())
.map(([name]) => name)
.sort();
}

function assertJsonRequestMatchesZodObject(
paths: Record<string, Record<string, unknown>>,
path: string,
method: string,
schema: ZodRequestObject
) {
const openApiSchema = jsonRequestSchema(paths, path, method);
assert.deepEqual(
Object.keys(openApiSchema?.properties ?? {}).sort(),
zodPropertyNames(schema),
`${method.toUpperCase()} ${path} request properties drifted from zod schema`
);
assert.deepEqual(
[...(openApiSchema?.required ?? [])].sort(),
zodRequiredPropertyNames(schema),
`${method.toUpperCase()} ${path} required request properties drifted from zod schema`
);
}

function responseObject(
paths: Record<string, Record<string, unknown>>,
path: string,
Expand Down Expand Up @@ -497,6 +553,22 @@ test("core JSON mutation routes document optional bodies and nested fields accur
]);
});

test("OpenAPI JSON request bodies stay aligned with zod input contracts", async () => {
const response = await app.request("/api/openapi.json");
const body = await response.json() as { paths: Record<string, Record<string, unknown>> };

for (const { path, method, schema } of [
{ path: "/api/permissions", method: "put", schema: permissionPolicyWriteSchema },
{ path: "/api/permissions/ask", method: "post", schema: createApprovalRequestSchema },
{ path: "/api/approvals/{id}/respond", method: "post", schema: respondApprovalRequestSchema },
{ path: "/api/approvals/{id}/delegate", method: "post", schema: delegateApprovalRequestSchema },
{ path: "/api/approvals/{id}/comments", method: "post", schema: addApprovalCommentRequestSchema },
{ path: "/api/workitems/{id}/evidence-bindings", method: "post", schema: useEvidenceForTaskRequestSchema }
] as const) {
assertJsonRequestMatchesZodObject(body.paths, path, method, schema);
}
});

test("project and drive OpenAPI routes document runtime path and query parameters", async () => {
const response = await app.request("/api/openapi.json");
const body = await response.json() as { paths: Record<string, Record<string, unknown>> };
Expand Down Expand Up @@ -1130,7 +1202,12 @@ test("Approval and permission OpenAPI contracts document decision and policy act
properties?: Record<string, unknown>;
} | undefined;
assert.deepEqual(deletePermissionNotFound?.required, ["ok", "error"]);
assert.deepEqual(deletePermissionNotFoundError?.properties?.code, { type: "string", enum: ["not_found"] });
// Old assertion expected generic not_found. That was wrong because permissions.revokePolicy
// returns the domain code permission_policy_not_found, and clients branch on that code.
assert.deepEqual(deletePermissionNotFoundError?.properties?.code, {
type: "string",
enum: ["permission_policy_not_found"]
});

assert.deepEqual(jsonRequestSchema(body.paths, "/api/permissions/ask", "post")?.required, ["action_pattern"]);
assert.deepEqual(Object.keys(jsonRequestProperties(body.paths, "/api/permissions/ask", "post")).sort(), [
Expand All @@ -1152,6 +1229,50 @@ test("Approval and permission OpenAPI contracts document decision and policy act
assert.ok(askData?.properties?.approval, "POST /api/permissions/ask missing pending approval schema");
});

test("OpenAPI error responses document approval, meeting, and work item mutation status matrices", async () => {
const response = await app.request("/api/openapi.json");
const body = await response.json() as { paths: Record<string, Record<string, unknown>> };

assertJsonErrorCodes(body.paths, "/api/workitems", "post", "409", ["workitem_state_conflict"]);
assertJsonErrorCodes(body.paths, "/api/permissions/{id}", "delete", "404", ["permission_policy_not_found"]);

assertJsonErrorCodes(body.paths, "/api/approvals/{id}/respond", "post", "401", ["not_identified"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/respond", "post", "403", ["invalid_client_token", "forbidden"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/respond", "post", "404", ["not_found"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/respond", "post", "409", ["approval_race"]);

assertJsonErrorCodes(body.paths, "/api/approvals/{id}/delegate", "post", "401", ["not_identified"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/delegate", "post", "403", ["invalid_client_token", "forbidden"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/delegate", "post", "404", ["not_found", "delegate_target_not_found"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/delegate", "post", "409", ["approval_race"]);
assertJsonErrorCodes(body.paths, "/api/approvals/{id}/delegate", "post", "422", ["delegate_to_requester", "delegate_target_cannot_view"]);

for (const path of [
"/api/meetings/projects/{projectId}/insights/{insightId}/draft",
"/api/meetings/projects/{projectId}/insights/{insightId}/dismiss"
] as const) {
assertJsonErrorCodes(body.paths, path, "post", "401", ["not_identified"]);
assertJsonErrorCodes(body.paths, path, "post", "403", ["invalid_client_token", "meeting_forbidden"]);
assertJsonErrorCodes(body.paths, path, "post", "404", ["meeting_not_found", "meeting_insight_not_found"]);
}

assertJsonErrorCodes(body.paths, "/api/meetings/workitems/{workItemId}/proposal-draft", "post", "401", ["not_identified"]);
assertJsonErrorCodes(body.paths, "/api/meetings/workitems/{workItemId}/proposal-draft", "post", "403", [
"invalid_client_token",
"forbidden",
"meeting_forbidden"
]);
assertJsonErrorCodes(body.paths, "/api/meetings/workitems/{workItemId}/proposal-draft", "post", "404", [
"not_found",
"meeting_not_found",
"meeting_insight_not_found"
]);
assertJsonErrorCodes(body.paths, "/api/meetings/workitems/{workItemId}/proposal-draft", "post", "409", [
"meeting_draft_source_missing",
"meeting_insight_dismissed"
]);
});

test("Proposal OpenAPI contracts document review, merge, and conflict action payloads", async () => {
const response = await app.request("/api/openapi.json");
const body = await response.json() as { paths: Record<string, Record<string, unknown>> };
Expand Down
41 changes: 40 additions & 1 deletion apps/api/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,7 @@ const permissionForbiddenResponse = jsonErrorStatusResponse(
const permissionPolicyNotFoundResponse = jsonErrorStatusResponse(
"404",
"Permission policy was not found",
["not_found"]
["permission_policy_not_found"]
).responses["404"];
const permissionPolicyListResponse = {
responses: {
Expand Down Expand Up @@ -1650,12 +1650,17 @@ const approvalListResponse = {
const approvalRespondResponse = {
responses: {
"200": jsonDataResponse(approvalRespondResultResponseSchema, "Approval decision result").responses["200"],
"401": approvalNotIdentifiedResponse,
"403": approvalReadForbiddenResponse,
"404": approvalNotFoundResponse,
"409": approvalRaceResponse
}
} as const;
const approvalDelegateResponse = {
responses: {
"200": jsonDataResponse(approvalDelegateResultResponseSchema, "Delegated approval result").responses["200"],
"401": approvalNotIdentifiedResponse,
"403": approvalReadForbiddenResponse,
"404": approvalDelegateNotFoundResponse,
"422": approvalDelegateSemanticResponse,
"409": approvalRaceResponse
Expand Down Expand Up @@ -2157,6 +2162,31 @@ const meetingPageResponseSchema = {
},
additionalProperties: false
} as const;
const meetingMutationNotIdentifiedResponse = jsonErrorStatusResponse(
"401",
"Meeting mutation requires an authenticated user",
["not_identified"]
).responses["401"];
const meetingInsightForbiddenResponse = jsonErrorStatusResponse(
"403",
"Meeting insight is not visible or mutable by the current user",
["invalid_client_token", "meeting_forbidden"]
).responses["403"];
const meetingInsightNotFoundResponse = jsonErrorStatusResponse(
"404",
"Meeting project or insight was not found",
["meeting_not_found", "meeting_insight_not_found"]
).responses["404"];
const meetingDraftProposalForbiddenResponse = jsonErrorStatusResponse(
"403",
"Meeting-created work item draft is not visible or mutable by the current user",
["invalid_client_token", "forbidden", "meeting_forbidden"]
).responses["403"];
const meetingDraftProposalNotFoundResponse = jsonErrorStatusResponse(
"404",
"Meeting-created work item draft or source insight was not found",
["not_found", "meeting_not_found", "meeting_insight_not_found"]
).responses["404"];
const notificationPageItemResponseSchema = {
type: "object",
required: ["id", "type", "severity", "status", "inbox_bucket", "title", "created_at", "updated_at"],
Expand Down Expand Up @@ -4212,6 +4242,9 @@ export function getOpenApiDocument() {
],
responses: {
...jsonOkResponse(meetingPageResponseSchema).responses,
"401": meetingMutationNotIdentifiedResponse,
"403": meetingInsightForbiddenResponse,
"404": meetingInsightNotFoundResponse,
...jsonErrorStatusResponse("409", "Meeting insight cannot be converted to a draft in its current state", [
"meeting_insight_not_pending",
"meeting_insight_draft_missing",
Expand All @@ -4230,6 +4263,9 @@ export function getOpenApiDocument() {
],
responses: {
...jsonOkResponse(meetingPageResponseSchema).responses,
"401": meetingMutationNotIdentifiedResponse,
"403": meetingInsightForbiddenResponse,
"404": meetingInsightNotFoundResponse,
...jsonErrorStatusResponse("409", "Meeting insight cannot be dismissed in its current state", [
"meeting_insight_not_pending"
]).responses
Expand All @@ -4243,6 +4279,9 @@ export function getOpenApiDocument() {
parameters: [pathUuidParameter("workItemId")],
responses: {
...jsonOkResponse(workItemDetailResponseSchema).responses,
"401": meetingMutationNotIdentifiedResponse,
"403": meetingDraftProposalForbiddenResponse,
"404": meetingDraftProposalNotFoundResponse,
...jsonErrorStatusResponse("409", "Meeting insight draft cannot create a proposal in its current state", [
"meeting_draft_source_missing",
"meeting_insight_dismissed"
Expand Down
32 changes: 6 additions & 26 deletions apps/desktop-webview/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
resolveDesktopPetWindowBridge
} from "./pet-window-bridge.js";
import { parseDesktopShellNavigatePayload } from "./shell-events.js";
import { handleDesktopSpotlightShellNavigate } from "./spotlight-shell-navigation.js";
import { appleGlassDesignSystemCss } from "./design-system.js";
import {
commandPaletteCss,
Expand All @@ -89,11 +90,9 @@ import { glassWindowCss } from "./glass-window.js";
import {
mountSpotlight,
type SpotlightManualDragFn,
type SpotlightResizeDirection,
type SpotlightResizeFn
} from "./spotlight/controller.js";
import { spotlightCss } from "./spotlight/css.js";
import { capabilityForShellRoute, entityIdFromShellRoute } from "./spotlight/state.js";
import { reviewProposalWithoutMerge } from "./spotlight/views/proposals.js";
import { isStaleDesktopClientTokenError } from "./auth-recovery.js";

Expand Down Expand Up @@ -1120,7 +1119,7 @@ const resizeMainWindow: SpotlightResizeFn = (width, height) => {
}
};

// 搜索条像系统 Spotlight 一样可拖动,边缘热区可缩放;浏览器开发态无 __TAURI__ → no-op。
// 搜索条像系统 Spotlight 一样可拖动;浏览器开发态无 __TAURI__ → no-op。
const dragMainWindow = (): void => {
const tauri = (globalThis as {
__TAURI__?: {
Expand All @@ -1147,19 +1146,6 @@ const moveMainWindowBy: SpotlightManualDragFn = (deltaX, deltaY): void => {
}
};

const resizeMainWindowFromEdge = (direction: SpotlightResizeDirection): void => {
const tauri = (globalThis as {
__TAURI__?: {
core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
};
}).__TAURI__;
const invoke = tauri?.core?.invoke ?? tauri?.invoke;
if (typeof invoke === "function") {
void invoke("start_main_window_resize_drag", { direction }).catch(() => undefined);
}
};

// M2:launcher 顶层 Esc → 隐藏主窗(关闭盒子),兑现 hello 卡「Esc 关闭」承诺。浏览器开发态无 __TAURI__ → no-op。
const dismissMainWindow = (): void => {
const tauri = (globalThis as {
Expand Down Expand Up @@ -1252,7 +1238,6 @@ async function bootSpotlight() {
resize: resizeMainWindow,
drag: dragMainWindow,
dragMove: moveMainWindowBy,
resizeDrag: resizeMainWindowFromEdge,
dismiss: dismissMainWindow,
onActionSettled: () => {
void refreshApprovalsBadge();
Expand All @@ -1264,15 +1249,10 @@ async function bootSpotlight() {
// 监听它 → 把盒子直接开到对应能力(回 "/" 则回 launcher)。这是 Cuu/外部入口与盒子联动的地基。
const shellListen = resolveDesktopShellListen();
void shellListen?.("navigate", (event) => {
const parsed = parseDesktopShellNavigatePayload(event.payload);
const cap = parsed ? capabilityForShellRoute(parsed.route) : undefined;
if (cap && parsed) {
// rank13:携带路由里的实体 id,让 workitem/proposals/replay 直接打开该项而非落到列表。
const id = entityIdFromShellRoute(parsed.route);
spotlight.openCapability(cap, id ? { id, route: parsed.route } : { route: parsed.route });
} else {
spotlight.reset();
}
handleDesktopSpotlightShellNavigate(event.payload, {
spotlight,
saveProjectContextFromRoute: saveDesktopCuuProjectContextFromRoute
});
});
// rank12:把「待你拍板」实时条数喂给 launcher 审批角标——盒子的核心承诺是一眼看到有几条待决策。
// 启动拉一次 + 每 30s + 窗口重新聚焦时刷新;best-effort,失败不更新角标、不影响盒子。
Expand Down
23 changes: 14 additions & 9 deletions apps/desktop-webview/src/liquid-glass-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import {
liquidGlassFilterCss,
liquidGlassFilterHtml,
rebuildWorkHubLiquidGlassFilters,
renderWorkHubLiquidGlassLayer
} from "./liquid-glass-filter.js";
Expand All @@ -19,14 +20,17 @@ test("liquid glass layer only refracts at the edge, without a colored backing su
assert.doesNotMatch(liquidGlassFilterCss, /\.wh-liquid-glass-warp--spotlight \.wh-liquid-glass-refract\{[^}]*backdrop-filter/u);
assert.doesNotMatch(liquidGlassFilterCss, /\.wh-liquid-glass-warp--pet \.wh-liquid-glass-refract\{[^}]*-webkit-backdrop-filter/u);
assert.doesNotMatch(liquidGlassFilterCss, /\.wh-liquid-glass-warp--spotlight \.wh-liquid-glass-refract\{[^}]*-webkit-backdrop-filter/u);
assert.match(liquidGlassFilterCss, /\.wh-liquid-glass-warp--pet \.wh-liquid-glass-edge\{backdrop-filter:url\(#workhub-liquid-glass-pet-filter\) blur\(var\(--wh-liquid-frost\)\)/u);
assert.match(liquidGlassFilterCss, /\.wh-liquid-glass-warp--spotlight \.wh-liquid-glass-edge\{backdrop-filter:url\(#workhub-liquid-glass-spotlight-filter\) blur\(var\(--wh-liquid-frost\)\)/u);
// 3-4: the old assertions pinned SVG url(#workhub-liquid-glass-*) edge filters, but all
// active desktop consumers hide those layers; keeping the URLs kept a dead generated-map path alive.
assert.doesNotMatch(liquidGlassFilterCss, /url\(#workhub-liquid-glass/u);
assert.doesNotMatch(liquidGlassFilterHtml, /<svg|<filter|workhub-liquid-glass-defs/u);
assert.doesNotMatch(liquidGlassFilterCss, /(?:^|[;{])filter:url\(#workhub-liquid-glass/u);
assert.doesNotMatch(liquidGlassFilterCss, /--wh-liquid-frost:(?:1[0-9]|[2-9][0-9])px/u);
});

test("liquid glass filters are not rewritten when surface geometry is unchanged", () => {
test("liquid glass filter rebuild is a no-op while SVG refraction is disabled", () => {
let innerHtmlWrites = 0;
let canvasCreates = 0;
const defs = {
dataset: {} as Record<string, string>,
_innerHTML: "",
Expand Down Expand Up @@ -58,15 +62,16 @@ test("liquid glass filters are not rewritten when surface geometry is unchanged"
const doc = {
getElementById: (id: string) => (id === "workhub-liquid-glass-defs" ? defs : null),
querySelector: () => element,
createElement: () => canvas
createElement: () => {
canvasCreates += 1;
return canvas;
}
} as unknown as Document;

rebuildWorkHubLiquidGlassFilters(doc);
rebuildWorkHubLiquidGlassFilters(doc);

assert.equal(innerHtmlWrites, 1);
assert.match(defs.innerHTML, /result="edge_mask"/u);
assert.match(defs.innerHTML, /<feComposite in="displaced_sat" in2="edge_mask" operator="in" result="edge_refracted"/u);
assert.match(defs.innerHTML, /<feComposite in="spec_faded" in2="edge_mask" operator="in" result="edge_specular"/u);
assert.doesNotMatch(defs.innerHTML, /<feBlend in="displaced_sat" in2="spec_faded"/u);
assert.equal(canvasCreates, 0);
assert.equal(innerHtmlWrites, 0);
assert.equal(defs.innerHTML, "");
});
Loading
Loading