Files
paseo/packages/app/src/composer/forge-auto-attach.test.tsx
T
nllptrxandMohamed Boudra 4a5716360b Feat/forge check semantics and followup fixes (#2337)
* feat(protocol): add forge check presentation metadata

Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.

* fix(gitea): model Actions jobs as tasks

Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.

* feat(app): distinguish forge check presentation states

* fix(gitlab): preserve pipeline job semantics

* refactor(app): centralize forge check presentation policy

Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.

* refactor: consolidate forge check handling

* feat(forge): auto-attach pasted forge references

* docs(compat): correct forge rollout metadata

* refactor(forge): model check nuances as traits

* fix(forge): harden checks from branch review

Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).

* fix(gitea): detect fork-PR Actions approval hold as action_required

gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).

* fix(gitea): resolve cross-fork PRs by head sha

The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).

* fix: resolve forge rebase drift

* fix: type forge auto-attach boundary

* fix(i18n): add Korean check status copy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-25 23:37:50 +02:00

726 lines
23 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
import { useComposerForgeAutoAttach } from "./forge-auto-attach";
type ForgeSearchPayload = ForgeSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: ForgeSearchItem = {
kind: "change_request",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature",
};
const pr202: ForgeSearchItem = {
...pr101,
number: 202,
title: "Attach second PR",
url: "https://github.com/acme/paseo/pull/202",
headRefName: "feature-two",
};
const issue202: ForgeSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
url: "https://github.com/acme/paseo/issues/202",
state: "open",
body: null,
labels: [],
baseRefName: null,
headRefName: null,
};
const gitlabMr73: ForgeSearchItem = {
forge: "gitlab",
kind: "change_request",
number: 73,
title: "Attach MR",
url: "https://gitlab.com/acme/paseo/-/merge_requests/73",
state: "opened",
body: null,
labels: [],
projectPath: "acme/paseo",
baseRefName: "main",
headRefName: "feature",
};
const giteaIssue27: ForgeSearchItem = {
forge: "gitea",
kind: "issue",
number: 27,
title: "Attach Gitea issue",
url: "https://gitea.example.com/acme/paseo/issues/27",
state: "open",
body: null,
labels: [],
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
initialText?: string;
onChangeRequestDetected?: () => void;
onChangeRequestAdded?: (item: ForgeSearchItem) => void;
remote?: string | null;
}
function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload {
return {
items,
authState: "authenticated",
error: null,
requestId,
};
}
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchForge(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [searchClient, setSearchClient] = useState(client);
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
const autoAttach = useComposerForgeAutoAttach({
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client: searchClient,
isConnected: true,
serverId: "server-1",
cwd: workingDirectory,
setAttachments,
onChangeRequestDetected: input.onChangeRequestDetected,
onChangeRequestAdded: input.onChangeRequestAdded,
});
return {
text,
setText,
setSearchClient,
setWorkingDirectory,
attachments,
setAttachments,
isResolving: autoAttach.isResolving,
markForgeAttachmentRemoved: autoAttach.markForgeAttachmentRemoved,
};
}
async function flushDebounce() {
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
await Promise.resolve();
});
}
describe("useComposerForgeAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const onChangeRequestDetected = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestDetected }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
});
expect(result.current.isResolving).toBe(true);
expect(onChangeRequestDetected).toHaveBeenCalledTimes(1);
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(result.current.isResolving).toBe(false);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
it("adds a matching pasted GitLab MR URL as a composer attachment", async () => {
vi.useFakeTimers();
const client = createSearchClient([gitlabMr73]);
const onChangeRequestDetected = vi.fn();
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(
() =>
useHarness(client, {
onChangeRequestDetected,
onChangeRequestAdded,
remote: "git@gitlab.com:acme/paseo.git",
}),
{ wrapper: createWrapper() },
);
act(() => {
result.current.setText("Review https://gitlab.com/acme/paseo/-/merge_requests/73/diffs");
});
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "forge_change_request", item: gitlabMr73 },
]);
expect(onChangeRequestDetected).toHaveBeenCalledTimes(1);
expect(onChangeRequestAdded).toHaveBeenCalledWith(gitlabMr73);
expect(client.calls).toEqual([{ cwd, query: "73", limit: 20 }]);
vi.useRealTimers();
});
it("adds a matching pasted self-hosted Gitea issue URL", async () => {
vi.useFakeTimers();
const client = createSearchClient([giteaIssue27]);
const { result } = renderHook(
() =>
useHarness(client, {
remote: "git@gitea.example.com:acme/paseo.git",
}),
{ wrapper: createWrapper() },
);
act(() => {
result.current.setText("See https://gitea.example.com/acme/paseo/issues/27");
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "forge_issue", item: giteaIssue27 }]);
expect(client.calls).toEqual([{ cwd, query: "27", limit: 20 }]);
vi.useRealTimers();
});
it("ignores URLs that do not match the current remote", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not add a second pill when the ref is already attached", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual(initialAttachments);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101]);
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
wrapper: createWrapper(),
});
act(() => {
result.current.markForgeAttachmentRemoved(initialAttachments[0]);
result.current.setAttachments([]);
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
expect(result.current.attachments).toEqual([]);
expect(client.calls).toEqual([]);
vi.useRealTimers();
});
it("handles multiple matching URLs from one paste", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101, issue202]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
);
});
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
]);
vi.useRealTimers();
});
it("reports pasted pull requests in source order when lookups finish out of order", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
.mockReturnValueOnce(secondLookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
await flushDebounce();
await act(async () => {
secondLookup.resolve(githubPayload([pr202], "search-202"));
await Promise.resolve();
});
expect(onChangeRequestAdded).not.toHaveBeenCalled();
expect(result.current.attachments).toEqual([]);
await act(async () => {
firstLookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
await vi.waitFor(() => {
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
expect(result.current.attachments).toEqual([
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_change_request", item: pr202 },
]);
});
vi.useRealTimers();
});
it("preserves pull request order across lookup batches", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
.mockReturnValueOnce(secondLookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setText(
"Review https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
expect(result.current.isResolving).toBe(true);
await flushDebounce();
await act(async () => {
secondLookup.resolve(githubPayload([pr202], "search-202"));
await Promise.resolve();
});
expect(onChangeRequestAdded).not.toHaveBeenCalled();
expect(result.current.attachments).toEqual([]);
expect(result.current.isResolving).toBe(true);
await act(async () => {
firstLookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
await vi.waitFor(() => {
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
expect(result.current.attachments).toEqual([
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_change_request", item: pr202 },
]);
expect(result.current.isResolving).toBe(false);
});
expect(client.searchForge).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it("uses the latest pull request order when URLs are reordered during debounce", async () => {
vi.useFakeTimers();
const client = createSearchClient([pr101, pr202]);
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/202 and https://github.com/acme/paseo/pull/101",
);
});
await flushDebounce();
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
expect(client.calls).toEqual([
{ cwd, query: "202", limit: 20 },
{ cwd, query: "101", limit: 20 },
]);
vi.useRealTimers();
});
it("uses the latest pull request order when active lookups are reordered", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
.mockReturnValueOnce(secondLookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
await flushDebounce();
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/202 and https://github.com/acme/paseo/pull/101",
);
});
await flushDebounce();
await act(async () => {
firstLookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
expect(onChangeRequestAdded).not.toHaveBeenCalled();
expect(result.current.attachments).toEqual([]);
expect(result.current.isResolving).toBe(true);
await act(async () => {
secondLookup.resolve(githubPayload([pr202], "search-202"));
await Promise.resolve();
});
await vi.waitFor(() => {
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
expect(result.current.attachments).toEqual([
{ kind: "forge_change_request", item: pr202 },
{ kind: "forge_change_request", item: pr101 },
]);
expect(result.current.isResolving).toBe(false);
});
expect(client.searchForge).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it("does not report a buffered pull request after its URL is removed", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
.mockReturnValueOnce(secondLookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
await flushDebounce();
await act(async () => {
secondLookup.resolve(githubPayload([pr202], "search-202"));
await Promise.resolve();
});
act(() => {
result.current.setText("Still https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
await act(async () => {
firstLookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
await vi.waitFor(() => {
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]);
});
vi.useRealTimers();
});
it("keeps resolving when a pending pull request URL is removed and re-added", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setText("");
});
expect(result.current.isResolving).toBe(false);
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
expect(result.current.isResolving).toBe(true);
await flushDebounce();
expect(client.searchForge).toHaveBeenCalledTimes(1);
await act(async () => {
lookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
await vi.waitFor(() => {
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(result.current.isResolving).toBe(false);
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]);
});
vi.useRealTimers();
});
it("releases a removed ref without waiting for its lookup to settle", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
.mockReturnValueOnce(secondLookup.promise),
};
const onChangeRequestAdded = vi.fn();
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
wrapper: createWrapper(),
});
act(() => {
result.current.setText(
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
);
});
await flushDebounce();
await act(async () => {
secondLookup.resolve(githubPayload([pr202], "search-202"));
await Promise.resolve();
});
expect(onChangeRequestAdded).not.toHaveBeenCalled();
expect(result.current.isResolving).toBe(true);
act(() => {
result.current.setText("Still https://github.com/acme/paseo/pull/202");
});
await flushDebounce();
await vi.waitFor(() => {
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202]]);
expect(result.current.isResolving).toBe(false);
});
await act(async () => {
firstLookup.resolve(githubPayload([], "search-101"));
await Promise.resolve();
});
vi.useRealTimers();
});
it("stays resolving when an unrelated attachment is added during lookup", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setAttachments([{ kind: "forge_issue", item: issue202 }]);
result.current.setText("Review https://github.com/acme/paseo/pull/101 please");
});
expect(result.current.isResolving).toBe(true);
await act(async () => {
lookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
expect(result.current.attachments).toEqual([
{ kind: "forge_issue", item: issue202 },
{ kind: "forge_change_request", item: pr101 },
]);
expect(result.current.isResolving).toBe(false);
vi.useRealTimers();
});
it("stops resolving when an in-flight PR URL is removed", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setText("");
});
expect(result.current.isResolving).toBe(false);
vi.useRealTimers();
});
it("ignores a lookup that finishes after the target changes", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setWorkingDirectory("/other-repo");
});
await flushDebounce();
await act(async () => {
lookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
expect(result.current.attachments).toEqual([]);
expect(client.searchForge).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("accepts a lookup after the transport client is replaced for the same target", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const firstClient: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const replacementClient = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() });
act(() => {
result.current.setText("Review https://github.com/acme/paseo/pull/101");
});
await flushDebounce();
act(() => {
result.current.setSearchClient(replacementClient);
});
await act(async () => {
lookup.resolve(githubPayload([pr101], "search-101"));
await Promise.resolve();
});
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(replacementClient.calls).toEqual([]);
vi.useRealTimers();
});
});