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:
7
.changeset/FN-8357-quality-verification-videos.md
Normal file
7
.changeset/FN-8357-quality-verification-videos.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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*;/);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
135
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user