FN-8289: add feature-video review artifacts
Add gated local feature-video capture to completed user-facing deliverables. - Capture loopback scenario WebM recordings through the existing artifact registry. - Keep recording failures non-blocking and cover gated, unsafe, and failed capture paths. - Document the scenario contract and package the Playwright runtime dependency. Files changed: .changeset/fn-8289-feature-video.md | 7 + docs/workflow-steps.md | 8 + packages/cli/package.json | 3 +- packages/cli/tsup.config.ts | 7 + packages/engine/package.json | 3 +- .../__tests__/executor-review-artifacts.test.ts | 44 ++++ packages/engine/src/executor.ts | 33 +++ .../src/review-artifacts/feature-video.test.ts | 75 +++++++ .../engine/src/review-artifacts/feature-video.ts | 226 +++++++++++++++++++++ packages/engine/src/review-artifacts/index.ts | 10 + pnpm-lock.yaml | 6 + 11 files changed, 420 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8289 Fusion-Task-Lineage: 8a34675a-2417-4669-a14a-b74c4aa99331 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8289-feature-video.md
Normal file
7
.changeset/fn-8289-feature-video.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Auto-generate a short feature-video artifact for user-facing task deliverables.
|
||||
category: feature
|
||||
dev: Gated by the reviewArtifacts mode; best-effort playwright-core WebM capture at executor completion is linked by taskId. Loopback-only scenarios; oversize output is rejected and capture failure never fails the task.
|
||||
@@ -932,3 +932,11 @@ Authoring surfaces:
|
||||

|
||||
|
||||
See also: [Task Management](./task-management.md) and [Settings Reference](./settings-reference.md).
|
||||
|
||||
## Best-effort Feature-Video Review Artifacts
|
||||
|
||||
When the project or task-level [`reviewArtifacts`](./settings-reference.md) policy permits a completed task, the executor attempts a short feature-video immediately before its clean handoff to review. It is an optional deliverable: browser discovery, navigation, recording, and artifact-registration failures are logged and never block completion or review handoff.
|
||||
|
||||
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.
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"react": "^19.2.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.0",
|
||||
"playwright-core": "^1.60.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typebox": "*"
|
||||
|
||||
@@ -412,6 +412,13 @@ const cliBuildConfig = {
|
||||
"cpu-features",
|
||||
"embedded-postgres",
|
||||
/^@embedded-postgres\//,
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||
The engine lazy-loads playwright-core only for a gated local feature-video.
|
||||
Keep it external because Playwright has optional Chromium BiDi internals that
|
||||
esbuild cannot resolve, while the published CLI installs this direct runtime dep.
|
||||
*/
|
||||
"playwright-core",
|
||||
],
|
||||
splitting: false,
|
||||
// Keep clean disabled so the dedicated plugin-sdk tsup config can emit into
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"esbuild": "^0.25.12",
|
||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"typebox": "^1.0.0"
|
||||
"typebox": "^1.0.0",
|
||||
"playwright-core": "^1.60.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "../review-artifacts/feature-video.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
afterEach(async () => { await Promise.all(cleanup.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
|
||||
|
||||
describe("TaskExecutor feature-video completion handoff", () => {
|
||||
const task = { id: "FN-video", description: "user-facing", column: "in-progress", dependencies: [], steps: [], currentStep: 0 } as any;
|
||||
|
||||
function makeStore() {
|
||||
return {
|
||||
on: vi.fn(), getSettings: vi.fn().mockResolvedValue({ reviewArtifacts: "on" }), getTask: vi.fn().mockResolvedValue({ ...task, prompt: "**Review Artifacts:** on" }),
|
||||
getTaskDocument: vi.fn().mockResolvedValue({ content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/" }) }),
|
||||
registerArtifact: vi.fn().mockResolvedValue({ id: "video-1" }),
|
||||
handoffToReview: vi.fn().mockResolvedValue({ ...task, column: "in-review" }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("runs the injectable capture seam before handing completed work to review", async () => {
|
||||
const store = makeStore();
|
||||
const dir = await mkdtemp(join(tmpdir(), "executor-video-test-"));
|
||||
cleanup.push(dir);
|
||||
const videoPath = join(dir, "recording.webm");
|
||||
await writeFile(videoPath, "webm");
|
||||
const client: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => undefined, video: () => ({ path: async () => videoPath }) }), close: async () => undefined }), close: async () => undefined }) };
|
||||
const capture = vi.fn((options) => generateFeatureVideo({ ...options, client, sleep: async () => undefined }));
|
||||
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: capture });
|
||||
await (executor as any).handoffTaskToReview(task, "fn_task_done");
|
||||
expect(capture).toHaveBeenCalledWith(expect.objectContaining({ task: expect.objectContaining({ id: task.id }), settings: { reviewArtifacts: "on" } }));
|
||||
expect(store.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id }));
|
||||
expect(store.handoffToReview).toHaveBeenCalledWith(task.id, expect.any(Object));
|
||||
});
|
||||
|
||||
it("preserves completion handoff when capture rejects", async () => {
|
||||
const store = makeStore();
|
||||
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: vi.fn().mockRejectedValue(new Error("browser crashed")) });
|
||||
await expect((executor as any).handoffTaskToReview(task, "fn_task_done")).resolves.toMatchObject({ column: "in-review" });
|
||||
expect(store.handoffToReview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
|
||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js";
|
||||
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
|
||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||
import {
|
||||
@@ -1634,6 +1635,8 @@ export interface TaskExecutorOptions {
|
||||
onStart?: (task: Task, worktreePath: string) => void;
|
||||
onComplete?: (task: Task) => void;
|
||||
onError?: (task: Task, error: Error) => void;
|
||||
/** Testable, best-effort completion-deliverable seam; production uses generateFeatureVideo. */
|
||||
reviewArtifactGenerator?: (options: GenerateFeatureVideoOptions) => Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>;
|
||||
/** Optional runtime-owned dispatch seam that lets a flag-gated workflow
|
||||
* interpreter own the authoritative lifecycle for default coding tasks.
|
||||
* Return true when the task was fully handled and legacy execute() should stop. */
|
||||
@@ -2235,6 +2238,7 @@ export class TaskExecutor {
|
||||
*/
|
||||
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
|
||||
const agentId = this.getRunContextFor(task.id)?.agentId;
|
||||
await this.generateCompletionFeatureVideo(task);
|
||||
if (reason.startsWith("workflow-")) {
|
||||
await ensureWorkflowCompletionSummary(this.store, task as TaskDetail, {
|
||||
reason,
|
||||
@@ -2269,6 +2273,35 @@ export class TaskExecutor {
|
||||
return handedOff;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||
A successful executor handoff may offer reviewers a short local feature-video, but
|
||||
capture is strictly best-effort. Bound and swallow this optional work before the
|
||||
review transition so browser, scenario, and artifact failures never delay or fail it.
|
||||
*/
|
||||
private async generateCompletionFeatureVideo(task: Task): Promise<void> {
|
||||
try {
|
||||
const [settings, detail] = await Promise.all([this.store.getSettings(), this.store.getTask(task.id)]);
|
||||
const generator = this.options.reviewArtifactGenerator ?? generateFeatureVideo;
|
||||
const result = await this.awaitFeatureVideoBounded(generator({ store: this.store, task: detail ?? task, settings }));
|
||||
executorLog.log(`${task.id}: feature-video ${result.status}${"reason" in result ? ` (${result.reason})` : ""}`);
|
||||
} catch (error) {
|
||||
executorLog.warn(`${task.id}: feature-video capture ignored: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitFeatureVideoBounded(result: Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>): Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
result,
|
||||
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("feature-video timeout")), 20_000); }),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private getModelRegistry(): Promise<ModelRegistry> {
|
||||
if (!this._modelRegistry) {
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
75
packages/engine/src/review-artifacts/feature-video.test.ts
Normal file
75
packages/engine/src/review-artifacts/feature-video.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "./feature-video.js";
|
||||
|
||||
const task = { id: "FN-8289", title: "Feature demo", prompt: "**Review Artifacts:** on" } as const;
|
||||
const scenario = { id: "doc", taskId: task.id, key: "review-artifact-scenario", content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/settings" }), revision: 1, author: "agent", createdAt: "", updatedAt: "" };
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
|
||||
|
||||
async function videoClient(bytes = Buffer.from("webm")): Promise<FeatureVideoBrowserClient> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "feature-video-test-"));
|
||||
tempDirs.push(dir);
|
||||
const path = join(dir, "video.webm");
|
||||
await writeFile(path, bytes);
|
||||
return {
|
||||
launch: vi.fn().mockResolvedValue({
|
||||
newContext: vi.fn().mockResolvedValue({
|
||||
newPage: vi.fn().mockResolvedValue({ goto: vi.fn().mockResolvedValue(undefined), video: () => ({ path: async () => path }) }),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function store(document: typeof scenario | null = scenario) {
|
||||
return {
|
||||
getTaskDocument: vi.fn().mockResolvedValue(document),
|
||||
registerArtifact: vi.fn().mockResolvedValue({ id: "artifact-1" }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateFeatureVideo", () => {
|
||||
it("registers a WebM video linked to its task through the existing registry", async () => {
|
||||
const artifactStore = store();
|
||||
const result = await generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(), sleep: async () => undefined });
|
||||
expect(result).toEqual({ status: "captured", artifactId: "artifact-1" });
|
||||
expect(artifactStore.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id, mimeType: "video/webm", data: Buffer.from("webm") }));
|
||||
});
|
||||
|
||||
it("short-circuits before reading a scenario or browser when policy is off", async () => {
|
||||
const artifactStore = store();
|
||||
const client = await videoClient();
|
||||
await expect(generateFeatureVideo({ store: artifactStore, task: { ...task, prompt: undefined }, settings: { reviewArtifacts: "off" }, client })).resolves.toEqual({ status: "skipped", reason: "gated-off" });
|
||||
expect(artifactStore.getTaskDocument).not.toHaveBeenCalled();
|
||||
expect(client.launch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, "no-scenario"],
|
||||
[{ ...scenario, content: "not json" }, "no-scenario"],
|
||||
[{ ...scenario, content: JSON.stringify({ baseUrl: "https://example.com", targetRoute: "/" }) }, "scenario-url-not-local"],
|
||||
])("skips missing or unsafe scenario contracts (%s)", async (document, reason) => {
|
||||
const artifactStore = store(document as typeof scenario | null);
|
||||
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient() })).resolves.toEqual({ status: "skipped", reason });
|
||||
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows browser and navigation failures without registering partial artifacts", async () => {
|
||||
const artifactStore = store();
|
||||
const unavailable: FeatureVideoBrowserClient = { launch: vi.fn().mockRejectedValue(new Error("missing chromium")) };
|
||||
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: unavailable })).resolves.toEqual({ status: "skipped", reason: "browser-unavailable" });
|
||||
const navigation: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => { throw new Error("offline"); }, video: () => null }), close: async () => undefined }), close: async () => undefined }) };
|
||||
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: navigation })).resolves.toEqual({ status: "skipped", reason: "navigation-failed" });
|
||||
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversize output without registration", async () => {
|
||||
const artifactStore = store();
|
||||
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(Buffer.alloc(11)), maxBytes: 10, sleep: async () => undefined })).resolves.toEqual({ status: "failed", reason: "size-cap-exceeded" });
|
||||
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
226
packages/engine/src/review-artifacts/feature-video.ts
Normal file
226
packages/engine/src/review-artifacts/feature-video.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
isReviewArtifactGenerationEligible,
|
||||
type Artifact,
|
||||
type ProjectSettings,
|
||||
type Task,
|
||||
type TaskDocument,
|
||||
} from "@fusion/core";
|
||||
|
||||
const SCENARIO_DOCUMENT_KEY = "review-artifact-scenario";
|
||||
const MAX_DURATION_MS = 15_000;
|
||||
const DEFAULT_DURATION_MS = 3_000;
|
||||
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const NAVIGATION_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface FeatureVideoPage {
|
||||
goto(url: string, options: { timeout: number; waitUntil: "load" }): Promise<unknown>;
|
||||
video(): { path(): Promise<string> } | null;
|
||||
}
|
||||
|
||||
export interface FeatureVideoContext {
|
||||
newPage(): Promise<FeatureVideoPage>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FeatureVideoBrowser {
|
||||
newContext(options: { recordVideo: { dir: string; size: { width: number; height: number } } }): Promise<FeatureVideoContext>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Injectable seam so unit tests never launch a real browser. */
|
||||
export interface FeatureVideoBrowserClient {
|
||||
launch(options: { executablePath?: string; headless: boolean }): Promise<FeatureVideoBrowser>;
|
||||
}
|
||||
|
||||
export interface FeatureVideoStore {
|
||||
getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null>;
|
||||
registerArtifact(input: {
|
||||
type: "video";
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mimeType: "video/webm";
|
||||
data: Buffer;
|
||||
authorId: string;
|
||||
authorType: "system";
|
||||
}): Promise<Artifact>;
|
||||
}
|
||||
|
||||
export type FeatureVideoResult =
|
||||
| { status: "skipped"; reason: "gated-off" | "no-scenario" | "scenario-url-not-local" | "browser-unavailable" | "navigation-failed" }
|
||||
| { status: "failed"; reason: "size-cap-exceeded" | "capture-failed" }
|
||||
| { status: "captured"; artifactId: string };
|
||||
|
||||
export interface GenerateFeatureVideoOptions {
|
||||
store: FeatureVideoStore;
|
||||
task: Pick<Task, "id" | "title"> & { prompt?: string };
|
||||
settings: Pick<ProjectSettings, "reviewArtifacts">;
|
||||
client?: FeatureVideoBrowserClient;
|
||||
executablePath?: string;
|
||||
durationMs?: number;
|
||||
maxBytes?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
baseUrl: string;
|
||||
targetRoute: string;
|
||||
flowScript?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||
Feature-video generation is a gated, best-effort completion deliverable. It uses the
|
||||
existing artifact registry (not a parallel media store), accepts only loopback scenario
|
||||
URLs, and returns a result for every failure so recording can never fail task completion.
|
||||
*/
|
||||
export function shouldGenerateReviewArtifacts(
|
||||
task: Pick<Task, "id"> & { prompt?: string },
|
||||
settings: Pick<ProjectSettings, "reviewArtifacts">,
|
||||
): boolean {
|
||||
return isReviewArtifactGenerationEligible(settings, task.prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a short WebM from the persisted local scenario contract and registers its
|
||||
* bytes through TaskStore. This boundary intentionally swallows all capture failures.
|
||||
*/
|
||||
export async function generateFeatureVideo(options: GenerateFeatureVideoOptions): Promise<FeatureVideoResult> {
|
||||
if (!shouldGenerateReviewArtifacts(options.task, options.settings)) {
|
||||
return { status: "skipped", reason: "gated-off" };
|
||||
}
|
||||
|
||||
let scenarioDocument: TaskDocument | null;
|
||||
try {
|
||||
scenarioDocument = await options.store.getTaskDocument(options.task.id, SCENARIO_DOCUMENT_KEY);
|
||||
} catch {
|
||||
return { status: "skipped", reason: "no-scenario" };
|
||||
}
|
||||
const scenario = parseScenario(scenarioDocument?.content);
|
||||
if (!scenario) return { status: "skipped", reason: "no-scenario" };
|
||||
const url = resolveLoopbackScenarioUrl(scenario);
|
||||
if (!url) return { status: "skipped", reason: "scenario-url-not-local" };
|
||||
|
||||
const executablePath = options.client ? options.executablePath : await probeBrowserExecutable(options.executablePath);
|
||||
if (!options.client && !executablePath) return { status: "skipped", reason: "browser-unavailable" };
|
||||
const client = options.client ?? await createPlaywrightFeatureVideoClient();
|
||||
if (!client) return { status: "skipped", reason: "browser-unavailable" };
|
||||
|
||||
let recordingDir: string | undefined;
|
||||
let browser: FeatureVideoBrowser | undefined;
|
||||
let context: FeatureVideoContext | undefined;
|
||||
try {
|
||||
recordingDir = await mkdtemp(join(tmpdir(), "fusion-feature-video-"));
|
||||
try {
|
||||
browser = await client.launch({ executablePath, headless: true });
|
||||
} catch {
|
||||
return { status: "skipped", reason: "browser-unavailable" };
|
||||
}
|
||||
context = await browser.newContext({ recordVideo: { dir: recordingDir, size: { width: 1280, height: 720 } } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(url, { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "load" });
|
||||
} catch {
|
||||
return { status: "skipped", reason: "navigation-failed" };
|
||||
}
|
||||
const video = page.video();
|
||||
if (!video) return { status: "failed", reason: "capture-failed" };
|
||||
await (options.sleep ?? defaultSleep)(Math.min(Math.max(options.durationMs ?? DEFAULT_DURATION_MS, 0), MAX_DURATION_MS));
|
||||
await context.close();
|
||||
context = undefined;
|
||||
const data = await readFile(await video.path());
|
||||
if (data.byteLength > (options.maxBytes ?? DEFAULT_MAX_BYTES)) {
|
||||
return { status: "failed", reason: "size-cap-exceeded" };
|
||||
}
|
||||
const artifact = await options.store.registerArtifact({
|
||||
type: "video",
|
||||
taskId: options.task.id,
|
||||
title: `Feature video: ${options.task.title ?? options.task.id}`,
|
||||
description: `Best-effort feature recording for ${scenario.targetRoute}.`,
|
||||
mimeType: "video/webm",
|
||||
data,
|
||||
authorId: "executor",
|
||||
authorType: "system",
|
||||
});
|
||||
return { status: "captured", artifactId: artifact.id };
|
||||
} catch {
|
||||
return { status: "failed", reason: "capture-failed" };
|
||||
} finally {
|
||||
await safeClose(context);
|
||||
await safeClose(browser);
|
||||
if (recordingDir) await rm(recordingDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function parseScenario(content: string | undefined): Scenario | undefined {
|
||||
if (!content) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(content) as Partial<Scenario>;
|
||||
return typeof value.baseUrl === "string" && typeof value.targetRoute === "string" && value.targetRoute.startsWith("/")
|
||||
? { baseUrl: value.baseUrl, targetRoute: value.targetRoute, ...(typeof value.flowScript === "string" ? { flowScript: value.flowScript } : {}) }
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoopbackScenarioUrl(scenario: Scenario): string | undefined {
|
||||
try {
|
||||
const base = new URL(scenario.baseUrl);
|
||||
if (!/^https?:$/.test(base.protocol) || !["127.0.0.1", "localhost", "::1"].includes(base.hostname)) return undefined;
|
||||
return new URL(scenario.targetRoute, base).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||
playwright-core does not download Chromium. Probe only known local executable locations
|
||||
before launch so an unavailable browser is a clean skip, never a completion failure.
|
||||
*/
|
||||
async function probeBrowserExecutable(explicit?: string): Promise<string | undefined> {
|
||||
const candidates = [
|
||||
explicit,
|
||||
process.env.FUSION_BROWSER_EXECUTABLE,
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
process.env.CHROME_PATH,
|
||||
...(process.platform === "darwin" ? [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
] : process.platform === "win32" ? [] : [
|
||||
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||
]),
|
||||
].filter((candidate): candidate is string => Boolean(candidate?.trim()));
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Continue with the next local candidate; no subprocess or download is used.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function createPlaywrightFeatureVideoClient(): Promise<FeatureVideoBrowserClient | undefined> {
|
||||
try {
|
||||
const playwright = await import("playwright-core") as unknown as { chromium: FeatureVideoBrowserClient };
|
||||
return playwright.chromium;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClose(value: { close(): Promise<void> } | undefined): Promise<void> {
|
||||
await value?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
10
packages/engine/src/review-artifacts/index.ts
Normal file
10
packages/engine/src/review-artifacts/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
generateFeatureVideo,
|
||||
shouldGenerateReviewArtifacts,
|
||||
type FeatureVideoBrowser,
|
||||
type FeatureVideoBrowserClient,
|
||||
type FeatureVideoContext,
|
||||
type FeatureVideoPage,
|
||||
type FeatureVideoResult,
|
||||
type GenerateFeatureVideoOptions,
|
||||
} from "./feature-video.js";
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
||||
node-pty:
|
||||
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
||||
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
||||
playwright-core:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.4
|
||||
@@ -560,6 +563,9 @@ importers:
|
||||
node-pty:
|
||||
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
||||
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
||||
playwright-core:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
proper-lockfile:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
|
||||
Reference in New Issue
Block a user