FN-8357: show verification videos in the Quality hub

Surface executor-produced task verification videos in the top-level Quality hub.

- Fetch review-artifact settings and video artifacts per selected project.
- Render tokenized inline videos with source-task navigation and responsive states.
- Add host interop aliases, coverage, documentation, and a minor changeset.

Files changed:
 .changeset/FN-8357-quality-verification-videos.md  |   7 +
 docs/settings-reference.md                         |   2 +-
 docs/workflow-steps.md                             |   2 +-
 packages/dashboard/tsconfig.app.json               |   1 +
 packages/dashboard/vite.config.ts                  |   2 +
 packages/dashboard/vitest.config.ts                |   2 +
 plugins/fusion-plugin-quality/package.json         |   1 +
 .../dashboard-view-verification-videos.test.tsx    | 131 ++++++++++++++++++
 .../src/dashboard-interop.d.ts                     |  20 +++
 .../fusion-plugin-quality/src/dashboard-view.css   |  87 ++++++++++++
 .../fusion-plugin-quality/src/dashboard-view.tsx   | 151 +++++++++++++++++++--
 plugins/fusion-plugin-quality/tsconfig.json        |   1 +
 plugins/fusion-plugin-quality/vitest.config.ts     |   8 ++
 pnpm-lock.yaml                                     | 135 +++++++++++++++++-
 14 files changed, 532 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-8357
Fusion-Task-Lineage: 728a5417-87ed-4203-850d-8ee108206766
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 19:51:58 -07:00
parent cf3f5e9bf0
commit c8b4e23ca4
14 changed files with 532 additions and 18 deletions

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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"),

View File

@@ -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"),

View File

@@ -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",

View File

@@ -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 }) => (
<header><h1>{title}</h1>{actions}</header>
),
}));
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<React.ComponentProps<typeof QualityDashboardView>["context"]> = {}) {
const openTaskDetail = vi.fn();
render(<QualityDashboardView context={{ projectId: "project-a", tasks: [{ id: "FN-8357" }] as never[], openTaskDetail, ...overrides }} />);
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(<QualityDashboardView context={{ projectId: "project-a", tasks: [], openTaskDetail: vi.fn() }} />);
expect(await screen.findByTestId("quality-verification-videos-empty")).toBeTruthy();
mockFetch({ artifacts: [executorVideo, { ...executorVideo, id: "artifact-second", title: "Feature video: Second task" }] });
rerender(<QualityDashboardView context={{ projectId: "project-b", tasks: [], openTaskDetail: vi.fn() }} />);
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(<QualityDashboardView context={{ tasks: [], openTaskDetail: vi.fn() }} />);
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*;/);
});
});

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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<Error> {
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<RunRow[]> {
/*
FNXC:Quality 2026-07-15-23:17:
@@ -36,23 +63,16 @@ async function fetchRuns(projectId: string): Promise<RunRow[]> {
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<string, unknown>; project?: Record<string, unknown> }): 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<string | null>(null);
const [loading, setLoading] = useState(false);
const [busyPreset, setBusyPreset] = useState<string | null>(null);
const [reviewArtifacts, setReviewArtifacts] = useState<ReviewArtifactsMode>("off");
const [verificationVideos, setVerificationVideos] = useState<ArtifactWithTask[]>([]);
const [verificationVideosProjectId, setVerificationVideosProjectId] = useState<string | null>(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<string, unknown>; project?: Record<string, unknown> };
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({
</table>
</div>
</section>
{/*
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 ? (
<section className="quality-verification-videos-card card" data-testid="quality-verification-videos">
<header className="quality-verification-videos-card__header">
<h3>Verification videos</h3>
<span className="quality-verification-videos-card__count">{verificationVideos.length} total</span>
</header>
{verificationVideos.length === 0 ? (
<p className="quality-verification-videos-card__empty" data-testid="quality-verification-videos-empty">
No verification videos captured yet.
</p>
) : (
<div className="quality-verification-videos-list">
{verificationVideos.map((artifact) => {
const task = context?.tasks.find((candidate) => candidate.id === artifact.taskId);
return (
<article className="quality-verification-video-row" key={artifact.id} data-testid="quality-verification-video-row">
<video
className="quality-verification-video-row__media"
controls
preload="metadata"
src={artifactMediaUrlWithToken(artifact.id, projectId)}
/>
<div className="quality-verification-video-row__details">
<h4>{artifact.title}</h4>
{task ? (
<button
type="button"
className="btn btn-sm"
onClick={() => context?.openTaskDetail(task)}
data-testid="quality-verification-video-open-task"
>
Open task
</button>
) : null}
</div>
</article>
);
})}
</div>
)}
</section>
) : (
<p className="quality-verification-videos-disabled" data-testid="quality-verification-videos-disabled">
Verification videos appear here when Review Artifacts is enabled.
</p>
)}
</>
)}
</div>

View File

@@ -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"]
}
},

View File

@@ -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)),

135
pnpm-lock.yaml generated
View File

@@ -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