diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index b641e38bb..6555f6b16 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -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"; @@ -20,6 +29,10 @@ interface ErrorBody { }; } +type ZodRequestObject = { + shape: Record boolean }>; +}; + const documentedMethods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]); const runtimeContractRouteIgnores = new Set(["/", "/openapi.json", "/api/openapi.json"]); @@ -121,6 +134,49 @@ function jsonErrorCodeProperty( return error?.properties?.code; } +function assertJsonErrorCodes( + paths: Record>, + 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>, + 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>, path: string, @@ -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> }; + + 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> }; @@ -1130,7 +1202,12 @@ test("Approval and permission OpenAPI contracts document decision and policy act properties?: Record; } | 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(), [ @@ -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> }; + + 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> }; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 152303cb9..d4e487982 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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: { @@ -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 @@ -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"], @@ -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", @@ -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 @@ -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" diff --git a/apps/desktop-webview/src/browser.ts b/apps/desktop-webview/src/browser.ts index 4f0281850..9fdc5c7db 100644 --- a/apps/desktop-webview/src/browser.ts +++ b/apps/desktop-webview/src/browser.ts @@ -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, @@ -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"; @@ -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__?: { @@ -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) => Promise }; - invoke?: (cmd: string, args?: Record) => Promise; - }; - }).__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 { @@ -1252,7 +1238,6 @@ async function bootSpotlight() { resize: resizeMainWindow, drag: dragMainWindow, dragMove: moveMainWindowBy, - resizeDrag: resizeMainWindowFromEdge, dismiss: dismissMainWindow, onActionSettled: () => { void refreshApprovalsBadge(); @@ -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,失败不更新角标、不影响盒子。 diff --git a/apps/desktop-webview/src/liquid-glass-filter.test.ts b/apps/desktop-webview/src/liquid-glass-filter.test.ts index 7f043c633..ed12e2275 100644 --- a/apps/desktop-webview/src/liquid-glass-filter.test.ts +++ b/apps/desktop-webview/src/liquid-glass-filter.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { liquidGlassFilterCss, + liquidGlassFilterHtml, rebuildWorkHubLiquidGlassFilters, renderWorkHubLiquidGlassLayer } from "./liquid-glass-filter.js"; @@ -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, / { +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, _innerHTML: "", @@ -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, /