feat(FN-4343): complete Step 2 — dual-signal frontend ux auto-skip

Fusion-Task-Id: FN-4343
Fusion-Task-Lineage: b880b71a-9250-4d9e-bdac-8298a73058f7
This commit is contained in:
Fusion
2026-05-13 19:15:12 -07:00
committed by gsxdsm
parent 578b71e8df
commit 540c5e16d8
2 changed files with 126 additions and 4 deletions

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
function createTask() {
return {
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress" as const,
dependencies: [],
steps: [{ name: "Preflight", status: "done" as const }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["frontend-ux-design"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function createFrontendStep() {
return {
id: "frontend-ux-design",
name: "Frontend UX Design",
description: "UI review",
prompt: "Review UI",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function mockDiffFiles(files: string[]) {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
if (typeof cmd === "string" && cmd.includes("git merge-base HEAD origin/main")) {
return Buffer.from("abc123\n");
}
if (typeof cmd === "string" && cmd.includes("git diff --name-only abc123..HEAD")) {
return Buffer.from(files.join("\n"));
}
return Buffer.from("");
});
}
describe("executor workflow step scope gating", () => {
beforeEach(() => {
resetExecutorMocks();
});
it.each([
{
name: "both signals empty",
diffFiles: [] as string[],
declaredFiles: [] as string[],
expectedSkip: false,
},
{
name: "diff only non-frontend",
diffFiles: ["packages/engine/src/executor.ts"],
declaredFiles: [],
expectedSkip: true,
},
{
name: "declared only non-frontend",
diffFiles: [],
declaredFiles: [".github/workflows/ci.yml"],
expectedSkip: true,
expectedLog: "declared File Scope contains no frontend/UI files",
},
{
name: "both present and both non-frontend",
diffFiles: ["packages/engine/src/executor.ts"],
declaredFiles: [".github/workflows/ci.yml"],
expectedSkip: true,
expectedLog: "declared File Scope contains no frontend/UI files",
},
])("FN-4343 auto-skip matrix: $name", async ({ diffFiles, declaredFiles, expectedSkip, expectedLog }) => {
const store = createMockStore();
const task = createTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(createFrontendStep() as any);
store.parseFileScopeFromPrompt.mockResolvedValue(declaredFiles);
mockDiffFiles(diffFiles);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
const executeStepSpy = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true, output: "ok" });
const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", {} as any);
expect(result).toEqual({ allPassed: true });
if (expectedSkip) {
expect(executeStepSpy).not.toHaveBeenCalled();
const statuses = store.updateTask.mock.calls.flatMap((call: any[]) => call[1]?.workflowStepResults ?? []).map((r: any) => r.status);
expect(statuses).toContain("skipped");
if (expectedLog) {
const logged = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
expect(logged.some((line: string) => line.includes(expectedLog))).toBe(true);
}
} else {
expect(executeStepSpy).toHaveBeenCalledTimes(1);
}
});
});

View File

@@ -5929,17 +5929,35 @@ ${failureFeedback}
if (this.isFrontendUxStep(ws)) {
try {
const scopedFiles = await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha);
if (scopedFiles.length > 0 && !this.hasFrontendFilesInScope(scopedFiles)) {
const diffScopedFiles = await this.captureModifiedFiles(worktreePath, currentTask.baseCommitSha);
const declaredScopedFiles = await this.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]);
const diffHasSignal = diffScopedFiles.length > 0;
const declaredHasSignal = declaredScopedFiles.length > 0;
const diffHasFrontendFiles = diffHasSignal && this.hasFrontendFilesInScope(diffScopedFiles);
const declaredHasFrontendFiles = declaredHasSignal && this.hasFrontendFilesInScope(declaredScopedFiles);
const shouldSkipForDiffOnly = diffHasSignal && !declaredHasSignal && !diffHasFrontendFiles;
const shouldSkipForDeclaredOnly = declaredHasSignal && !diffHasSignal && !declaredHasFrontendFiles;
const shouldSkipForBothSignals = diffHasSignal && declaredHasSignal && !diffHasFrontendFiles && !declaredHasFrontendFiles;
if (shouldSkipForDiffOnly || shouldSkipForDeclaredOnly || shouldSkipForBothSignals) {
const skippedForDeclaredScope = shouldSkipForDeclaredOnly || shouldSkipForBothSignals;
results.push({
workflowStepId: ws.id,
workflowStepName: ws.name,
phase: stepPhase,
status: "skipped",
output: "No frontend/UI files in diff scope — auto-skipped (FN-3906)",
output: skippedForDeclaredScope
? "Declared File Scope contains no frontend/UI files — auto-skipped (FN-4343)"
: "No frontend/UI files in diff scope — auto-skipped (FN-3906)",
});
await this.store.updateTask(task.id, { workflowStepResults: results });
await this.store.logEntry(task.id, "[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope");
await this.store.logEntry(
task.id,
skippedForDeclaredScope
? "[pre-merge] Auto-skipped Frontend UX Design — declared File Scope contains no frontend/UI files (FN-4343)"
: "[pre-merge] Auto-skipped Frontend UX Design — no frontend/UI files in diff scope",
);
continue;
}
} catch {