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:
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user