From bcdad279d78043c6ddd727d994f2e7390bfbd1da Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 13:52:31 -0700 Subject: [PATCH] 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) --- .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 +- .../executor-review-artifacts.test.ts | 44 ++++ packages/engine/src/executor.ts | 33 +++ .../review-artifacts/feature-video.test.ts | 75 ++++++ .../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(-) create mode 100644 .changeset/fn-8289-feature-video.md create mode 100644 packages/engine/src/__tests__/executor-review-artifacts.test.ts create mode 100644 packages/engine/src/review-artifacts/feature-video.test.ts create mode 100644 packages/engine/src/review-artifacts/feature-video.ts create mode 100644 packages/engine/src/review-artifacts/index.ts diff --git a/.changeset/fn-8289-feature-video.md b/.changeset/fn-8289-feature-video.md new file mode 100644 index 0000000000..fdd101ad67 --- /dev/null +++ b/.changeset/fn-8289-feature-video.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 446410eb49..76e52ad7ea 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -932,3 +932,11 @@ Authoring surfaces: ![Workflow quality-gate results](./screenshots/workflow-steps.png) 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. diff --git a/packages/cli/package.json b/packages/cli/package.json index ecdb718673..8406fb63e0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": "*" diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index bd3926a296..948dd4a383 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -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 diff --git a/packages/engine/package.json b/packages/engine/package.json index 4399bf792c..e867dcedf9 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -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", diff --git a/packages/engine/src/__tests__/executor-review-artifacts.test.ts b/packages/engine/src/__tests__/executor-review-artifacts.test.ts new file mode 100644 index 0000000000..55786f161c --- /dev/null +++ b/packages/engine/src/__tests__/executor-review-artifacts.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 645ee9533e..1ef1f8ff4f 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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; /** 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 { 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 { + 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): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + result, + new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("feature-video timeout")), 20_000); }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + private getModelRegistry(): Promise { if (!this._modelRegistry) { const authStorage = createFusionAuthStorage(); diff --git a/packages/engine/src/review-artifacts/feature-video.test.ts b/packages/engine/src/review-artifacts/feature-video.test.ts new file mode 100644 index 0000000000..6622bf2e41 --- /dev/null +++ b/packages/engine/src/review-artifacts/feature-video.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/engine/src/review-artifacts/feature-video.ts b/packages/engine/src/review-artifacts/feature-video.ts new file mode 100644 index 0000000000..f08bc0e43e --- /dev/null +++ b/packages/engine/src/review-artifacts/feature-video.ts @@ -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; + video(): { path(): Promise } | null; +} + +export interface FeatureVideoContext { + newPage(): Promise; + close(): Promise; +} + +export interface FeatureVideoBrowser { + newContext(options: { recordVideo: { dir: string; size: { width: number; height: number } } }): Promise; + close(): Promise; +} + +/** Injectable seam so unit tests never launch a real browser. */ +export interface FeatureVideoBrowserClient { + launch(options: { executablePath?: string; headless: boolean }): Promise; +} + +export interface FeatureVideoStore { + getTaskDocument(taskId: string, key: string): Promise; + registerArtifact(input: { + type: "video"; + taskId: string; + title: string; + description: string; + mimeType: "video/webm"; + data: Buffer; + authorId: string; + authorType: "system"; + }): Promise; +} + +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 & { prompt?: string }; + settings: Pick; + client?: FeatureVideoBrowserClient; + executablePath?: string; + durationMs?: number; + maxBytes?: number; + sleep?: (ms: number) => Promise; +} + +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 & { prompt?: string }, + settings: Pick, +): 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 { + 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; + 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 { + 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 { + try { + const playwright = await import("playwright-core") as unknown as { chromium: FeatureVideoBrowserClient }; + return playwright.chromium; + } catch { + return undefined; + } +} + +async function safeClose(value: { close(): Promise } | undefined): Promise { + await value?.close().catch(() => undefined); +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/engine/src/review-artifacts/index.ts b/packages/engine/src/review-artifacts/index.ts new file mode 100644 index 0000000000..ef708b971f --- /dev/null +++ b/packages/engine/src/review-artifacts/index.ts @@ -0,0 +1,10 @@ +export { + generateFeatureVideo, + shouldGenerateReviewArtifacts, + type FeatureVideoBrowser, + type FeatureVideoBrowserClient, + type FeatureVideoContext, + type FeatureVideoPage, + type FeatureVideoResult, + type GenerateFeatureVideoOptions, +} from "./feature-video.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72cd83ebe3..71d6289a44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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