fix(FN-branch-group): repair CI failures — execFile mock compatibility, TaskCard narrowing, e2e memo race

- resolve execFile lazily via namespace import in coordinator/merger/
  task-lifecycle so the repo's exec-only child_process test mocks load again
  (10+ engine suites failed at import); dashboard.test.ts mock gains execFile
  so the argv-based git probes hit the mock instead of spawning real git
- TaskCard: capture optional branchContext.groupId into a const (narrowing
  doesn't survive into the onClick closure; app tsconfig caught it in CI)
- planning e2e: bounded poll past the 2.5s listTasks startup memo that served
  a pre-landing snapshot on fast CI runs
This commit is contained in:
gsxdsm
2026-06-03 17:57:00 -07:00
parent 0be074f8c2
commit 1557fc5a47
6 changed files with 52 additions and 13 deletions

View File

@@ -275,10 +275,21 @@ const {
vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
// execFile mirrors exec's success-callback contract: the new argv-based git
// probes (pushTaskBranchToOrigin / gitCommandSucceeds) must hit the mock, not
// spawn real git against this test's fake cwds.
const mockExecFile = ((_file: string, _args?: unknown, optsOrCb?: unknown, cbMaybe?: unknown) => {
const callback = [optsOrCb, cbMaybe, _args].find((v) => typeof v === "function") as
| ((err: null, stdout: string, stderr: string) => void)
| undefined;
if (callback) callback(null, "", "");
return { pid: 12346, stdout: null, stderr: null, on: vi.fn(), once: vi.fn(), kill: vi.fn() };
}) as unknown as typeof original.execFile;
return {
...original,
exec: mockExec,
execSync: mockExecSync,
execFile: mockExecFile,
};
});

View File

@@ -13,10 +13,16 @@
* - Full PR lifecycle orchestration (create → status check → merge)
*/
import { exec, execFile } from "node:child_process";
import { exec } from "node:child_process";
import * as childProcess from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// `execFile` is resolved lazily through the namespace import so test mocks that
// only stub `exec`/`execSync` (the repo's established node:child_process mock
// convention) can still load this module; `execFile` is only required when a
// code path actually shells out.
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";

View File

@@ -1929,19 +1929,22 @@ function TaskCardComponent({
)}
{task.branchContext?.groupId && (() => {
const { branchContext } = task;
if (!branchContext?.groupId) return null;
// Capture into a const: narrowing on the optional groupId does not
// survive into the onClick closure below.
const groupId = branchContext?.groupId;
if (!branchContext || !groupId) return null;
return (
<span
className="card-branch-chip"
title={
branchContext.assignmentMode === "shared" && branchMetadata.branch
? `${branchContext.groupId} · ${branchMetadata.branch}`
: branchContext.groupId
? `${groupId} · ${branchMetadata.branch}`
: groupId
}
onClick={(event) => {
if (!onOpenGroupModal) return;
event.stopPropagation();
onOpenGroupModal(branchContext.groupId);
onOpenGroupModal(groupId);
}}
>
<span className="card-branch-label">
@@ -1950,7 +1953,7 @@ function TaskCardComponent({
<span className="card-branch-value">
{branchContext.assignmentMode === "shared" && branchMetadata.branch
? branchMetadata.branch
: branchContext.groupId}
: groupId}
</span>
</span>
);

View File

@@ -189,8 +189,16 @@ describe("U8 end-to-end: single managed group PR (planning + mission)", () => {
expect((await aiMergeTask(store, rootDir, second.id, { syncGroupPr })).merged).toBe(true);
await store.updateTask(second.id, { column: "done" } as any);
// Completion gate now satisfied (canonical predicate).
const members = (await store.listTasksByBranchGroup(group.id)) as Task[];
// Completion gate now satisfied (canonical predicate). listTasks carries
// a 2.5s startup memo that can serve a pre-landing snapshot on fast CI
// runs — poll past it (bounded) so this and the promote gate below read
// fresh member state through the real listTasksByBranchGroup path.
let members: Task[] = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
members = (await store.listTasksByBranchGroup(group.id)) as Task[];
if (evaluateBranchGroupCompletion({ members, group }).complete) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
expect(evaluateBranchGroupCompletion({ members, group }).complete).toBe(true);
// Promote → EXACTLY ONE PR via createGroupPr; persisted open.

View File

@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import * as childProcess from "node:child_process";
import { promisify } from "node:util";
import type { BranchGroup, BranchGroupPrState, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
@@ -8,7 +8,12 @@ import { resolveIntegrationBranch } from "./integration-branch.js";
// argv-based git invocation: arguments are passed as an array (no shell), so
// branch names like `foo$(touch /tmp/x)` can never trigger command substitution.
// Defense-in-depth alongside store-level validateBranchGroupBranchName.
const execFileAsync = promisify(execFile);
// `execFile` is resolved lazily through the namespace import so test mocks that
// only stub `exec`/`execSync` (the repo's established node:child_process mock
// convention) can still load this module; `execFile` is only required when a
// code path actually shells out.
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
export interface BranchGroupMergeRouting {
branchGroup: BranchGroup;

View File

@@ -1,11 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { execSync, exec, execFile } from "node:child_process";
import { execSync, exec } from "node:child_process";
import * as childProcess from "node:child_process";
import { promisify } from "node:util";
import { IDENTITY_GUARD_BYPASS_ENV } from "./worktree-hooks.js";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// `execFile` is resolved lazily through the namespace import so test mocks that
// only stub `exec`/`execSync` (the repo's established node:child_process mock
// convention) can still load this module; `execFile` is only required when a
// code path actually shells out.
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
/**
* Env for merger-driven `git commit` calls so the identity-guard pre-commit