diff --git a/.changeset/FN-8357-quality-verification-videos.md b/.changeset/FN-8357-quality-verification-videos.md new file mode 100644 index 0000000000..7a7e68a6c5 --- /dev/null +++ b/.changeset/FN-8357-quality-verification-videos.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Quality hub now shows task verification videos when review artifacts are enabled. +category: feature +dev: Quality plugin dashboard view reads type="video" executor feature-video artifacts (authorType==="system" && authorId==="executor") via the host /api/artifacts route, gated on effective reviewArtifacts !== "off"; plays them inline via the bridged artifactMediaUrlWithToken helper and opens source tasks through the plugin context openTaskDetail. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4dc6d9c965..a855c2f3f7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -592,7 +592,7 @@ Default notes: | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | | `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. | | `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. | -| `reviewArtifacts` | `"off" \| "user-facing" \| "on"` | `"off"` | Controls automatic review-deliverable generation. `"user-facing"` permits only user-facing tasks (identified by their `## Frontend UX Criteria` contract, or explicitly with `**Review Artifact Task Type:** user-facing`); backend/trivial classifications remain off. `"on"` permits every task classification. A task may override the project setting with its `PROMPT.md` header `**Review Artifacts:** off|user-facing|on`; header override takes precedence over project setting, then the conservative off fallback. | +| `reviewArtifacts` | `"off" \| "user-facing" \| "on"` | `"off"` | Controls automatic review-deliverable generation. `"user-facing"` permits only user-facing tasks (identified by their `## Frontend UX Criteria` contract, or explicitly with `**Review Artifact Task Type:** user-facing`); backend/trivial classifications remain off. `"on"` permits every task classification. When enabled, executor-generated task verification videos are available in the top-level Quality hub. A task may override the project setting with its `PROMPT.md` header `**Review Artifacts:** off|user-facing|on`; header override takes precedence over project setting, then the conservative off fallback. | | `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. | | `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). | | `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. | diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 76e52ad7ea..e902e78cb9 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -939,4 +939,4 @@ When the project or task-level [`reviewArtifacts`](./settings-reference.md) poli The MVP requires a persisted `review-artifact-scenario` task document containing JSON such as `{ "baseUrl": "http://127.0.0.1:5173", "targetRoute": "/settings" }`. The URL must be `http` or `https` on `127.0.0.1`, `localhost`, or `::1`; missing, malformed, remote, or unreachable scenarios are skipped. Fusion does not start or manage this server. An optional `flowScript` identifier is accepted for future registered flows; unknown identifiers use the default navigate-and-settle recording. -Capture uses local Chromium through `playwright-core` and records WebM. Recording is capped at 15 seconds (normally three seconds); output over the size cap is rejected without artifact registration rather than trimmed or re-encoded. A successful recording is registered through the normal artifact registry as `type="video"`, `mimeType="video/webm"`, and linked to its task, so existing review-artifact galleries display it. +Capture uses local Chromium through `playwright-core` and records WebM. Recording is capped at 15 seconds (normally three seconds); output over the size cap is rejected without artifact registration rather than trimmed or re-encoded. A successful recording is registered through the normal artifact registry as `type="video"`, `mimeType="video/webm"`, and linked to its task, so existing review-artifact galleries display it. When review artifacts are enabled, executor-generated verification videos also appear in the top-level Quality hub. diff --git a/packages/dashboard/tsconfig.app.json b/packages/dashboard/tsconfig.app.json index 67a6312c05..ad2dc630c2 100644 --- a/packages/dashboard/tsconfig.app.json +++ b/packages/dashboard/tsconfig.app.json @@ -11,6 +11,7 @@ "node-pty": ["./src/types/node-pty/index.d.ts"], "@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"], "@fusion/dashboard/app/components/ViewHeader": ["./app/components/ViewHeader.tsx"], + "@fusion/dashboard/app/api/task-content": ["./app/api/task-content.ts"], "@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"], "@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"], "@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"] diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index c200b1026d..8e9a2b658a 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -134,6 +134,8 @@ export default defineConfig({ "@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"), // FNXC:PluginBuild 2026-06-22-03:50: Bundled plugin source can import the dashboard's shared ViewHeader through the package export; Vite needs the same source alias during dashboard builds so plugin UI normalization does not fail only in CI merge builds. "@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"), + // FNXC:Quality 2026-07-19-12:00: The bundled Quality plugin needs the host's token-appended artifact URL helper because native video loads cannot attach authorization headers. + "@fusion/dashboard/app/api/task-content": resolve(__dirname, "app/api/task-content.ts"), "@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"), "@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"), "@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"), diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 77e6697b2f..445e0075fb 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -515,6 +515,8 @@ export default defineConfig({ "@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"), "@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"), "@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"), + // FNXC:Quality 2026-07-19-12:00: Keep the Quality plugin's tokenized artifact-media bridge resolvable under host Vitest just as it is in the production dashboard bundle. + "@fusion/dashboard/app/api/task-content": resolve(__dirname, "app/api/task-content.ts"), "@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"), "@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"), "@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"), diff --git a/plugins/fusion-plugin-quality/package.json b/plugins/fusion-plugin-quality/package.json index 7338a17fa4..cdd858f57d 100644 --- a/plugins/fusion-plugin-quality/package.json +++ b/plugins/fusion-plugin-quality/package.json @@ -33,6 +33,7 @@ "@testing-library/react": "^16.3.2", "@types/node": "^25.5.2", "@types/react": "^19.0.0", + "jsdom": "^27.4.0", "react": "^19.0.0", "react-dom": "^19.2.4", "typescript": "^5.7.0", diff --git a/plugins/fusion-plugin-quality/src/__tests__/dashboard-view-verification-videos.test.tsx b/plugins/fusion-plugin-quality/src/__tests__/dashboard-view-verification-videos.test.tsx new file mode 100644 index 0000000000..04202ff3a5 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/dashboard-view-verification-videos.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +vi.mock("@fusion/dashboard/app/components/ViewHeader", () => ({ + ViewHeader: ({ title, actions }: { title: string; actions?: ReactNode }) => ( +

{title}

{actions}
+ ), +})); + +const { artifactMediaUrlWithToken } = vi.hoisted(() => ({ + artifactMediaUrlWithToken: vi.fn((id: string, projectId?: string) => `/tokenized/${id}?projectId=${projectId}`), +})); +vi.mock("@fusion/dashboard/app/api/task-content", () => ({ artifactMediaUrlWithToken })); + +import { QualityDashboardView, isVerificationVideo } from "../dashboard-view"; + +const executorVideo = { + id: "artifact-executor", + type: "video" as const, + title: "Feature video: Executor task", + taskId: "FN-8357", + authorType: "system", + authorId: "executor", +}; +const operatorVideo = { + id: "artifact-operator", + type: "video" as const, + title: "Feature video: Operator upload", + taskId: "FN-8358", + authorType: "user", + authorId: "operator", +}; + +function response(body: unknown, ok = true): Response { + return { ok, statusText: ok ? "OK" : "Request failed", json: async () => body, text: async () => typeof body === "string" ? body : JSON.stringify(body) } as Response; +} + +function mockFetch({ reviewArtifacts = "on", artifacts = [executorVideo], settingsOk = true, artifactsOk = true }: { + reviewArtifacts?: "off" | "user-facing" | "on"; + artifacts?: typeof executorVideo[]; + settingsOk?: boolean; + artifactsOk?: boolean; +} = {}) { + globalThis.fetch = vi.fn((input: string | URL | Request) => { + const url = String(input); + if (url.startsWith("/api/settings/scopes")) return Promise.resolve(response({ global: { reviewArtifacts }, project: {} }, settingsOk)); + if (url.startsWith("/api/artifacts?type=video")) return Promise.resolve(response(artifacts, artifactsOk)); + if (url.startsWith("/api/plugins/fusion-plugin-quality/runs")) return Promise.resolve(response({ runs: [] })); + throw new Error(`Unexpected fetch ${url}`); + }) as typeof fetch; +} + +function renderView(overrides: Partial["context"]> = {}) { + const openTaskDetail = vi.fn(); + render(); + return { openTaskDetail }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("QualityDashboardView verification videos", () => { + it("identifies only executor-produced system videos, not title-matched uploads", () => { + expect(isVerificationVideo(executorVideo)).toBe(true); + expect(isVerificationVideo(operatorVideo)).toBe(false); + }); + + it("shows only a disabled note when review artifacts are off", async () => { + mockFetch({ reviewArtifacts: "off" }); + renderView(); + expect(await screen.findByTestId("quality-verification-videos-disabled")).toBeTruthy(); + expect(screen.queryByTestId("quality-verification-videos")).toBeNull(); + }); + + it.each(["user-facing", "on"] as const)("renders executor videos when review artifacts is %s", async (reviewArtifacts) => { + mockFetch({ reviewArtifacts, artifacts: [executorVideo, operatorVideo as typeof executorVideo] }); + renderView(); + expect(await screen.findByTestId("quality-verification-videos")).toBeTruthy(); + expect(screen.getAllByTestId("quality-verification-video-row")).toHaveLength(1); + expect(screen.getByText(executorVideo.title)).toBeTruthy(); + expect(screen.queryByText(operatorVideo.title)).toBeNull(); + expect(document.querySelector("video")?.getAttribute("src")).toBe("/tokenized/artifact-executor?projectId=project-a"); + }); + + it("shows an empty state and lists multiple videos when available", async () => { + mockFetch({ artifacts: [] }); + const { rerender } = render(); + expect(await screen.findByTestId("quality-verification-videos-empty")).toBeTruthy(); + + mockFetch({ artifacts: [executorVideo, { ...executorVideo, id: "artifact-second", title: "Feature video: Second task" }] }); + rerender(); + await waitFor(() => expect(screen.getAllByTestId("quality-verification-video-row")).toHaveLength(2)); + }); + + it.each([ + ["settings", { settingsOk: false }], + ["artifacts", { artifactsOk: false }], + ] as const)("surfaces %s fetch failures", async (_source, result) => { + mockFetch(result); + renderView(); + expect((await screen.findByRole("alert")).textContent).not.toBe(""); + }); + + it("opens the matching source task through the context contract", async () => { + mockFetch(); + const { openTaskDetail } = renderView(); + fireEvent.click(await screen.findByTestId("quality-verification-video-open-task")); + expect(openTaskDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-8357" })); + }); + + it("does not render the section without a selected project", () => { + render(); + expect(screen.getByTestId("quality-hub")).toBeTruthy(); + expect(screen.queryByTestId("quality-verification-videos")).toBeNull(); + expect(screen.queryByTestId("quality-verification-videos-disabled")).toBeNull(); + }); + + it("constrains video cards and media at the canonical mobile breakpoint", () => { + const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "dashboard-view.css"), "utf8"); + const mobileCss = css.slice(css.indexOf("@media (max-width: 768px), (max-height: 480px)")); + expect(mobileCss).toMatch(/\.quality-verification-videos-card,[\s\S]*?max-width\s*:\s*100%\s*;/); + expect(mobileCss).toMatch(/\.quality-verification-video-row__media[\s\S]*?max-width\s*:\s*100%\s*;/); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts b/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts index 8fbff80c87..134d09d55e 100644 --- a/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts +++ b/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts @@ -1,13 +1,33 @@ // Ambient host interop (no runtime dependency on @fusion/dashboard). // Vite aliases resolve these to the real dashboard sources at build time. +// FNXC:Quality 2026-07-19-12:00: The Quality plugin consumes existing artifact media through this narrow host bridge so inline video elements receive the tokenized URL they require without importing dashboard client APIs. // Mirrors fusion-plugin-compound-engineering/src/dashboard-interop.d.ts. declare module "@fusion/dashboard/app/plugins/types" { + import type { Task } from "@fusion/core"; + export interface PluginDashboardViewContext { projectId?: string; + tasks: Task[]; + openTaskDetail: (task: Task, initialTab?: string) => void; } } +declare module "@fusion/dashboard/app/api/task-content" { + export type ArtifactType = "document" | "image" | "video" | "audio" | "other"; + + export interface ArtifactWithTask { + id: string; + type: ArtifactType; + title: string; + taskId?: string; + authorId?: string; + authorType?: string; + } + + export function artifactMediaUrlWithToken(id: string, projectId?: string): string; +} + declare module "@fusion/dashboard/app/components/ViewHeader" { import type { ComponentType, ReactNode } from "react"; import type { LucideProps } from "lucide-react"; diff --git a/plugins/fusion-plugin-quality/src/dashboard-view.css b/plugins/fusion-plugin-quality/src/dashboard-view.css index 96551de06b..8ee2322f91 100644 --- a/plugins/fusion-plugin-quality/src/dashboard-view.css +++ b/plugins/fusion-plugin-quality/src/dashboard-view.css @@ -173,6 +173,82 @@ or bare h2/table inline styles. display: contents; } +.quality-verification-videos-card { + display: flex; + flex-direction: column; + gap: var(--space-md); + min-width: 0; + padding: var(--space-md) var(--space-lg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} + +.quality-verification-videos-card__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.quality-verification-videos-card__header h3, +.quality-verification-video-row__details h4 { + margin: 0; + color: var(--text); +} + +.quality-verification-videos-card__header h3 { + font-size: 0.95rem; + font-weight: 600; +} + +.quality-verification-videos-card__count, +.quality-verification-videos-card__empty, +.quality-verification-videos-disabled { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; +} + +.quality-verification-videos-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); + gap: var(--space-md); + min-width: 0; +} + +.quality-verification-video-row { + display: flex; + flex-direction: column; + gap: var(--space-sm); + min-width: 0; + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.quality-verification-video-row__media { + display: block; + width: 100%; + max-width: 100%; + border-radius: var(--radius-sm); +} + +.quality-verification-video-row__details { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + min-width: 0; +} + +.quality-verification-video-row__details h4 { + overflow-wrap: anywhere; + font-size: 0.875rem; + font-weight: 600; +} + /* FNXC:Quality 2026-07-16-12:45: FN-8131: On mobile, Quality's wide preset and refresh action cluster must give the title its own full-width row and wrap below it instead of shrinking the title to a `Q…` ellipsis or clipping buttons. This mirrors InsightsView FN-7830 while preserving the shared desktop header clamp. @@ -200,4 +276,15 @@ FN-8131: On mobile, Quality's wide preset and refresh action cluster must give t max-width: 100%; margin-left: 0; } + + .quality-verification-videos-card, + .quality-verification-video-row, + .quality-verification-video-row__media { + max-width: 100%; + } + + .quality-verification-video-row__details { + align-items: flex-start; + flex-direction: column; + } } diff --git a/plugins/fusion-plugin-quality/src/dashboard-view.tsx b/plugins/fusion-plugin-quality/src/dashboard-view.tsx index 433ee4c317..8b539d6998 100644 --- a/plugins/fusion-plugin-quality/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-quality/src/dashboard-view.tsx @@ -2,6 +2,11 @@ import "./dashboard-view.css"; import { useCallback, useEffect, useState, type ReactElement } from "react"; import { Play, RefreshCw, ShieldCheck } from "lucide-react"; import { ViewHeader } from "@fusion/dashboard/app/components/ViewHeader"; +import { + artifactMediaUrlWithToken, + type ArtifactWithTask, +} from "@fusion/dashboard/app/api/task-content"; +import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; /* FNXC:Quality 2026-07-14-21:45: @@ -15,8 +20,16 @@ actions. Removes ad-hoc padding/h2/inline table styles that made Quality look un Insights / Compound Engineering / Goals. */ -export interface QualityDashboardViewContext { - projectId?: string; +export type QualityDashboardViewContext = Pick< + PluginDashboardViewContext, + "projectId" | "tasks" | "openTaskDetail" +>; + +type ReviewArtifactsMode = "off" | "user-facing" | "on"; + +/** Returns true only for executor-produced system verification videos. */ +export function isVerificationVideo(artifact: ArtifactWithTask): boolean { + return artifact.type === "video" && artifact.authorType === "system" && artifact.authorId === "executor"; } interface RunRow { @@ -29,6 +42,20 @@ interface RunRow { taskId?: string; } +async function responseError(res: Response): Promise { + const text = await res.text(); + let message = text || res.statusText; + try { + const parsed = JSON.parse(text) as { error?: unknown }; + if (typeof parsed.error === "string" && parsed.error.trim()) { + message = parsed.error; + } + } catch { + // keep text body + } + return new Error(message); +} + async function fetchRuns(projectId: string): Promise { /* FNXC:Quality 2026-07-15-23:17: @@ -36,23 +63,16 @@ async function fetchRuns(projectId: string): Promise { rendering an empty history that looks like "no runs yet". */ const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`); - if (!res.ok) { - const text = await res.text(); - let message = text || res.statusText; - try { - const parsed = JSON.parse(text) as { error?: unknown }; - if (typeof parsed.error === "string" && parsed.error.trim()) { - message = parsed.error; - } - } catch { - // keep text body - } - throw new Error(message); - } + if (!res.ok) throw await responseError(res); const data = (await res.json()) as { runs?: RunRow[] }; return data.runs ?? []; } +function reviewArtifactsMode(scopes: { global?: Record; project?: Record }): ReviewArtifactsMode { + const value = scopes.project?.reviewArtifacts ?? scopes.global?.reviewArtifacts; + return value === "user-facing" || value === "on" ? value : "off"; +} + function formatWhen(iso: string): string { const ms = Date.parse(iso); if (!Number.isFinite(ms)) return iso; @@ -93,6 +113,54 @@ export function QualityDashboardView({ const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [busyPreset, setBusyPreset] = useState(null); + const [reviewArtifacts, setReviewArtifacts] = useState("off"); + const [verificationVideos, setVerificationVideos] = useState([]); + const [verificationVideosProjectId, setVerificationVideosProjectId] = useState(null); + + useEffect(() => { + if (!projectId) { + setReviewArtifacts("off"); + setVerificationVideos([]); + setVerificationVideosProjectId(null); + return; + } + + // FNXC:Quality 2026-07-19-19:30: Do not render a prior project's verification + // evidence while the newly selected project's settings and artifacts are loading. + setReviewArtifacts("off"); + setVerificationVideos([]); + setVerificationVideosProjectId(null); + + const controller = new AbortController(); + const loadVerificationVideos = async () => { + try { + const query = `projectId=${encodeURIComponent(projectId)}`; + const [settingsResponse, artifactsResponse] = await Promise.all([ + fetch(`/api/settings/scopes?${query}`, { signal: controller.signal }), + fetch(`/api/artifacts?type=video&${query}`, { signal: controller.signal }), + ]); + if (!settingsResponse.ok) throw await responseError(settingsResponse); + if (!artifactsResponse.ok) throw await responseError(artifactsResponse); + + const scopes = (await settingsResponse.json()) as { global?: Record; project?: Record }; + const artifacts = (await artifactsResponse.json()) as ArtifactWithTask[]; + if (!controller.signal.aborted) { + setReviewArtifacts(reviewArtifactsMode(scopes)); + setVerificationVideos(artifacts.filter(isVerificationVideo)); + setVerificationVideosProjectId(projectId); + } + } catch (err) { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : String(err)); + } + } + }; + + void loadVerificationVideos(); + return () => controller.abort(); + }, [projectId]); + + const verificationVideosEnabled = verificationVideosProjectId === projectId && reviewArtifacts !== "off"; const refresh = useCallback(async () => { if (!projectId) return; @@ -255,6 +323,59 @@ export function QualityDashboardView({ + + {/* + FNXC:Quality 2026-07-19-12:00: + Quality surfaces task feature-video verification evidence only while reviewArtifacts is enabled. + Executor/system provenance prevents operator uploads from appearing as verification evidence; native video + loads use the tokenized media URL, and source tasks open through the host context rather than a URL route. + */} + {verificationVideosEnabled ? ( +
+
+

Verification videos

+ {verificationVideos.length} total +
+ {verificationVideos.length === 0 ? ( +

+ No verification videos captured yet. +

+ ) : ( +
+ {verificationVideos.map((artifact) => { + const task = context?.tasks.find((candidate) => candidate.id === artifact.taskId); + return ( +
+
+ ); + })} +
+ )} +
+ ) : ( +

+ Verification videos appear here when Review Artifacts is enabled. +

+ )} )} diff --git a/plugins/fusion-plugin-quality/tsconfig.json b/plugins/fusion-plugin-quality/tsconfig.json index 5d3276b7c3..33884ba7b9 100644 --- a/plugins/fusion-plugin-quality/tsconfig.json +++ b/plugins/fusion-plugin-quality/tsconfig.json @@ -7,6 +7,7 @@ "types": ["react", "node"], "paths": { "@fusion/dashboard/app/components/ViewHeader": ["./src/dashboard-interop.d.ts"], + "@fusion/dashboard/app/api/task-content": ["./src/dashboard-interop.d.ts"], "@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"] } }, diff --git a/plugins/fusion-plugin-quality/vitest.config.ts b/plugins/fusion-plugin-quality/vitest.config.ts index c5a2e23f18..7e438374f4 100644 --- a/plugins/fusion-plugin-quality/vitest.config.ts +++ b/plugins/fusion-plugin-quality/vitest.config.ts @@ -19,6 +19,14 @@ export default defineConfig({ find: /^@fusion-plugin-examples\/quality$/, replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)), }, + { + find: "@fusion/dashboard/app/api/task-content", + replacement: fileURLToPath(new URL("../../packages/dashboard/app/api/task-content.ts", import.meta.url)), + }, + { + find: "@fusion/dashboard/app/components/ViewHeader", + replacement: fileURLToPath(new URL("../../packages/dashboard/app/components/ViewHeader.tsx", import.meta.url)), + }, { find: "@fusion/core", replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dfbbcb298..87a7a2b49c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1210,6 +1210,9 @@ importers: '@types/react': specifier: ^19.0.0 version: 19.2.14 + jsdom: + specifier: ^27.4.0 + version: 27.4.0 react: specifier: ^19.0.0 version: 19.2.4 @@ -1221,7 +1224,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@27.4.0)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) plugins/fusion-plugin-reports: dependencies: @@ -1354,6 +1357,9 @@ packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@adiwajshing/keyed-db@0.2.4': resolution: {integrity: sha512-yprSnAtj80/VKuDqRcFFLDYltoNV8tChNwFfIgcf6PGD4sjzWIBgs08pRuTqGH5mk5wgL6PBRSsMCZqtZwzFEw==} @@ -1381,10 +1387,16 @@ packages: zod: optional: true + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + '@asamuzakjp/css-color@5.0.1': resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + '@asamuzakjp/dom-selector@7.0.4': resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4443,6 +4455,10 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4609,6 +4625,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -5812,6 +5832,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsdom@29.0.1: resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -7905,10 +7934,18 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + whatwg-url@16.0.1: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -8111,6 +8148,8 @@ snapshots: 7zip-bin@5.2.0: {} + '@acemir/cssom@0.9.31': {} + '@adiwajshing/keyed-db@0.2.4': {} '@adobe/css-tools@4.4.4': {} @@ -8145,6 +8184,14 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + '@asamuzakjp/css-color@5.0.1': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -8153,6 +8200,14 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.2.7 + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + '@asamuzakjp/dom-selector@7.0.4': dependencies: '@asamuzakjp/nwsapi': 2.3.9 @@ -11781,6 +11836,13 @@ snapshots: css.escape@1.5.1: {} + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.2.7 + csstype@3.2.3: {} curve25519-js@0.0.4: {} @@ -11971,6 +12033,11 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -13355,6 +13422,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + jsdom@29.0.1: dependencies: '@asamuzakjp/css-color': 5.0.1 @@ -15754,6 +15849,37 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@27.4.0)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.5.2 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.10.1 + jsdom: 27.4.0 + transitivePeerDependencies: + - msw + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 @@ -15868,8 +15994,15 @@ snapshots: whatwg-mimetype@3.0.0: optional: true + whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-url@16.0.1: dependencies: '@exodus/bytes': 1.15.0