fix(backend): prevent task reorder during silent session resume (#555)
This commit is contained in:
@@ -550,26 +550,14 @@ func (e *Executor) persistResumeState(ctx context.Context, taskID string, sessio
|
||||
}
|
||||
}
|
||||
|
||||
// startAgentProcessOnResume starts the agent process asynchronously after a session resume,
|
||||
// syncing the task state (REVIEW or unchanged) on success.
|
||||
// startAgentProcessOnResume starts the agent process asynchronously after a session resume.
|
||||
// Task state is managed by workflow triggers and stream handlers elsewhere; this callback
|
||||
// just logs successful process start.
|
||||
func (e *Executor) startAgentProcessOnResume(ctx context.Context, taskID string, session *models.TaskSession, agentExecutionID string) {
|
||||
e.runAgentProcessAsync(ctx, taskID, session.ID, agentExecutionID, func(updCtx context.Context) {
|
||||
// Agent resumed successfully - sync task state with session state.
|
||||
if session.State == models.TaskSessionStateWaitingForInput {
|
||||
if updateErr := e.updateTaskState(updCtx, taskID, v1.TaskStateReview); updateErr != nil {
|
||||
e.logger.Warn("failed to update task state to REVIEW after resume",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(updateErr))
|
||||
} else {
|
||||
e.logger.Debug("task state synced to REVIEW after resume (session waiting for input)",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID))
|
||||
}
|
||||
} else {
|
||||
e.logger.Debug("agent resumed successfully, task state unchanged",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.String("session_state", string(session.State)))
|
||||
}
|
||||
e.logger.Debug("agent resumed successfully",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.String("session_state", string(session.State)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
|
||||
// Single source of truth for bucketing a task into review / in_progress /
|
||||
// backlog. Used by the sidebar sort in task-switcher and the per-item
|
||||
// spinner icon in task-item so the two views always agree.
|
||||
|
||||
const REVIEW_STATES = new Set<TaskSessionState>([
|
||||
"WAITING_FOR_INPUT",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
]);
|
||||
const IN_PROGRESS_STATES = new Set<TaskSessionState>(["RUNNING"]);
|
||||
const TASK_STATE_REVIEW = new Set<TaskState | undefined>(["REVIEW", "COMPLETED"]);
|
||||
const TASK_STATE_IN_PROGRESS = new Set<TaskState | undefined>(["IN_PROGRESS", "SCHEDULING"]);
|
||||
|
||||
export type TaskBucket = "review" | "in_progress" | "backlog";
|
||||
|
||||
export function classifyTask(
|
||||
sessionState: TaskSessionState | undefined,
|
||||
taskState?: TaskState,
|
||||
): TaskBucket {
|
||||
if (!sessionState) {
|
||||
if (TASK_STATE_REVIEW.has(taskState)) return "review";
|
||||
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
// STARTING is a transient session state (e.g. during auto-resume after a
|
||||
// backend restart). Defer to the workflow task state so a task in REVIEW
|
||||
// does not flicker out of the review bucket while the agent reattaches.
|
||||
if (sessionState === "STARTING" && taskState) {
|
||||
if (TASK_STATE_REVIEW.has(taskState)) return "review";
|
||||
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
if (REVIEW_STATES.has(sessionState)) return "review";
|
||||
if (IN_PROGRESS_STATES.has(sessionState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
|
||||
import { classifyTask } from "./task-classify";
|
||||
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
||||
|
||||
type DiffStats = {
|
||||
@@ -57,6 +58,15 @@ function formatRelativeTime(dateString: string): string {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Delegates to the shared classifier in task-switcher so the sidebar bucket
|
||||
// and the per-task running spinner always agree. A task whose workflow state
|
||||
// is REVIEW or COMPLETED must not render as "running" when its session
|
||||
// transiently cycles through STARTING/RUNNING (e.g. during an agent auto-
|
||||
// resume after a backend restart).
|
||||
function computeIsInProgress(state?: TaskState, sessionState?: TaskSessionState): boolean {
|
||||
return classifyTask(sessionState, state) === "in_progress";
|
||||
}
|
||||
|
||||
function handleTaskItemKeyDown(e: React.KeyboardEvent<HTMLDivElement>, onClick?: () => void): void {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
@@ -80,14 +90,7 @@ function TaskStateIcon({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isReview =
|
||||
sessionState === "WAITING_FOR_INPUT" ||
|
||||
sessionState === "COMPLETED" ||
|
||||
sessionState === "FAILED" ||
|
||||
sessionState === "CANCELLED" ||
|
||||
state === "REVIEW" ||
|
||||
state === "COMPLETED";
|
||||
if (isReview) {
|
||||
if (classifyTask(sessionState, state) === "review") {
|
||||
return (
|
||||
<IconCircleCheck
|
||||
data-testid="task-state-review"
|
||||
@@ -234,11 +237,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
prInfo,
|
||||
}: TaskItemProps) {
|
||||
const effectiveMenuOpen = menuOpen || isDeleting === true;
|
||||
const isInProgress =
|
||||
state === "IN_PROGRESS" ||
|
||||
state === "SCHEDULING" ||
|
||||
sessionState === "STARTING" ||
|
||||
sessionState === "RUNNING";
|
||||
const isInProgress = computeIsInProgress(state, sessionState);
|
||||
const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyTask } from "./task-classify";
|
||||
import { statePriority, sortByStateThenCreated, type TaskSwitcherItem } from "./task-switcher";
|
||||
|
||||
const EARLY = "2026-01-01T00:00:00Z";
|
||||
|
||||
function task(overrides: Partial<TaskSwitcherItem>): TaskSwitcherItem {
|
||||
return { id: "t", title: "t", ...overrides };
|
||||
}
|
||||
|
||||
describe("classifyTask", () => {
|
||||
it("buckets WAITING_FOR_INPUT as review", () => {
|
||||
expect(classifyTask("WAITING_FOR_INPUT")).toBe("review");
|
||||
});
|
||||
|
||||
it("buckets RUNNING as in_progress", () => {
|
||||
expect(classifyTask("RUNNING")).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("buckets STARTING with REVIEW task state as review", () => {
|
||||
expect(classifyTask("STARTING", "REVIEW")).toBe("review");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStateThenCreated (regression: silent resume reorder)", () => {
|
||||
it("sorts review (turn finished) above in_progress (running)", () => {
|
||||
// Regression: when a task is auto-resumed after backend restart, its
|
||||
// session transiently cycles WAITING_FOR_INPUT -> STARTING -> RUNNING ->
|
||||
// WAITING_FOR_INPUT. The sidebar must NOT promote it above other
|
||||
// turn-finished tasks during the RUNNING flicker.
|
||||
const running = task({
|
||||
id: "running",
|
||||
sessionState: "RUNNING",
|
||||
createdAt: EARLY,
|
||||
});
|
||||
const waiting = task({
|
||||
id: "waiting",
|
||||
sessionState: "WAITING_FOR_INPUT",
|
||||
createdAt: EARLY,
|
||||
});
|
||||
|
||||
expect(statePriority(waiting)).toBeLessThan(statePriority(running));
|
||||
expect([running, waiting].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
|
||||
"waiting",
|
||||
"running",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not move a transiently-RUNNING task above older turn-finished tasks", () => {
|
||||
// Older turn-finished task A and a newer turn-finished task B that gets
|
||||
// resumed (so its sessionState briefly flips to RUNNING). With the
|
||||
// pre-fix priority (in_progress=0), B would jump to the top. With the
|
||||
// fix (review=0), B stays in its original position relative to A.
|
||||
const a = task({
|
||||
id: "a-old-waiting",
|
||||
sessionState: "WAITING_FOR_INPUT",
|
||||
createdAt: EARLY,
|
||||
});
|
||||
const b = task({
|
||||
id: "b-newer-resuming",
|
||||
sessionState: "RUNNING",
|
||||
createdAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
const sorted = [a, b].sort(sortByStateThenCreated).map((t) => t.id);
|
||||
expect(sorted[0]).toBe("a-old-waiting");
|
||||
expect(sorted[1]).toBe("b-newer-resuming");
|
||||
});
|
||||
|
||||
it("orders backlog after both review and in_progress", () => {
|
||||
const backlog = task({ id: "bk", sessionState: undefined });
|
||||
const running = task({ id: "ru", sessionState: "RUNNING" });
|
||||
const review = task({ id: "rv", sessionState: "WAITING_FOR_INPUT" });
|
||||
|
||||
expect([backlog, running, review].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
|
||||
"rv",
|
||||
"ru",
|
||||
"bk",
|
||||
]);
|
||||
});
|
||||
|
||||
it("breaks ties within a bucket by createdAt descending", () => {
|
||||
const older = task({
|
||||
id: "older",
|
||||
sessionState: "WAITING_FOR_INPUT",
|
||||
createdAt: EARLY,
|
||||
});
|
||||
const newer = task({
|
||||
id: "newer",
|
||||
sessionState: "WAITING_FOR_INPUT",
|
||||
createdAt: "2026-02-01T00:00:00Z",
|
||||
});
|
||||
|
||||
expect([older, newer].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@kandev/ui/context-menu";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { classifyTask } from "./task-classify";
|
||||
import { TaskItem } from "./task-item";
|
||||
|
||||
export type TaskSwitcherItem = {
|
||||
@@ -71,44 +72,15 @@ type RepoGroup = {
|
||||
tasks: TaskSwitcherItem[];
|
||||
};
|
||||
|
||||
const REVIEW_STATES = new Set<TaskSessionState>([
|
||||
"WAITING_FOR_INPUT",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
]);
|
||||
const IN_PROGRESS_STATES = new Set<TaskSessionState>(["RUNNING"]);
|
||||
const TASK_STATE_REVIEW = new Set<TaskState | undefined>(["REVIEW", "COMPLETED"]);
|
||||
const TASK_STATE_IN_PROGRESS = new Set<TaskState | undefined>(["IN_PROGRESS"]);
|
||||
|
||||
function classifyTask(
|
||||
sessionState: TaskSessionState | undefined,
|
||||
taskState?: TaskState,
|
||||
): "review" | "in_progress" | "backlog" {
|
||||
if (!sessionState) {
|
||||
if (TASK_STATE_REVIEW.has(taskState)) return "review";
|
||||
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
if (sessionState === "STARTING" && taskState) {
|
||||
if (TASK_STATE_REVIEW.has(taskState)) return "review";
|
||||
if (TASK_STATE_IN_PROGRESS.has(taskState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
if (REVIEW_STATES.has(sessionState)) return "review";
|
||||
if (IN_PROGRESS_STATES.has(sessionState)) return "in_progress";
|
||||
return "backlog";
|
||||
}
|
||||
|
||||
function statePriority(task: TaskSwitcherItem): number {
|
||||
export function statePriority(task: TaskSwitcherItem): number {
|
||||
const bucket = classifyTask(task.sessionState, task.state);
|
||||
if (bucket === "in_progress") return 0;
|
||||
if (bucket === "review") return 1;
|
||||
if (bucket === "review") return 0;
|
||||
if (bucket === "in_progress") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number {
|
||||
// State first, then newest createdAt first (descending)
|
||||
export function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number {
|
||||
// Review (turn finished) first, then in_progress, then backlog; newest createdAt within bucket
|
||||
return (
|
||||
statePriority(a) - statePriority(b) || (b.createdAt ?? "").localeCompare(a.createdAt ?? "")
|
||||
);
|
||||
|
||||
@@ -209,6 +209,13 @@ export class ApiClient {
|
||||
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
|
||||
}
|
||||
|
||||
async updateTaskState(
|
||||
taskId: string,
|
||||
state: "BACKLOG" | "IN_PROGRESS" | "REVIEW" | "COMPLETED",
|
||||
): Promise<void> {
|
||||
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state });
|
||||
}
|
||||
|
||||
async listAgents(): Promise<{ agents: Agent[]; total: number }> {
|
||||
return this.request("GET", "/api/v1/agents");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Regression: when the backend restarts and auto-resumes a task whose
|
||||
// session was WAITING_FOR_INPUT, the task must NEVER transition into the
|
||||
// in_progress / Running sidebar bucket. Before the fix, the resume flow
|
||||
// cycled the session through STARTING/RUNNING states which briefly
|
||||
// displaced the task into the Running bucket at the top of the sidebar.
|
||||
// This test polls the sidebar continuously across the full resume window
|
||||
// to catch any transient Running flicker.
|
||||
|
||||
test.describe("Session resume — task state stability", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("resuming after backend restart does not flicker task into Running bucket", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// 1. Create task and run it to WAITING_FOR_INPUT.
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Resume Stable",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCardByTitle("Resume Stable");
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.taskInSection("Resume Stable", "Turn Finished")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 2. Restart backend and reload — triggers auto-resume via SSR + WS.
|
||||
await backend.restart();
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
|
||||
// 3. Continuously assert the task NEVER appears in the Running bucket
|
||||
// across a fixed 10s window after reload. The composer stays visible
|
||||
// throughout a silent resume (the fix intentionally keeps the
|
||||
// workflow state stable), so we cannot use idleInput visibility as
|
||||
// a stop signal — we must poll for a fixed duration to catch any
|
||||
// transient flicker the backend may still emit while reconnecting.
|
||||
const runningLocator = session.taskInSection("Resume Stable", "Running");
|
||||
const probeWindowMs = 10_000;
|
||||
const deadline = Date.now() + probeWindowMs;
|
||||
let polls = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const runningCount = await runningLocator.count();
|
||||
expect(runningCount, `task flickered into Running bucket during resume (poll ${polls})`).toBe(
|
||||
0,
|
||||
);
|
||||
polls++;
|
||||
await testPage.waitForTimeout(100);
|
||||
}
|
||||
// Ensure we actually polled meaningfully across the window.
|
||||
expect(polls).toBeGreaterThanOrEqual(50);
|
||||
|
||||
// 4. Final settled state: still Turn Finished, not Running.
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 60_000 });
|
||||
await expect(session.taskInSection("Resume Stable", "Turn Finished")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.taskInSection("Resume Stable", "Running")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// Regression: the sidebar statePriority must sort "Turn Finished" (review)
|
||||
// tasks ABOVE "Running" (in_progress) tasks. Before this fix the priority
|
||||
// was inverted, which caused a task whose session transiently cycled
|
||||
// through RUNNING during auto-resume (after a backend restart) to jump to
|
||||
// the top of the sidebar and displace other turn-finished tasks.
|
||||
//
|
||||
// This test asserts the sort order deterministically by seeding two tasks
|
||||
// with explicit states via the API: one REVIEW, one IN_PROGRESS. The
|
||||
// review task must render above the in_progress task in the session
|
||||
// sidebar regardless of createdAt order.
|
||||
|
||||
test.describe("Session sidebar — sort order", () => {
|
||||
test("review (turn finished) tasks sort above in_progress (running) tasks", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create an anchor task with an agent so we can open a session page
|
||||
// and view the sidebar in its normal SSR-hydrated context.
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Anchor Session",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
// Create the two ordering fixtures. Create the REVIEW one FIRST so it
|
||||
// has an older createdAt — this rules out createdAt-DESC as the reason
|
||||
// for any correct ordering we observe. The only way the newer
|
||||
// IN_PROGRESS task ends up BELOW the older REVIEW task is the state
|
||||
// bucket priority being review < in_progress.
|
||||
const review = await apiClient.createTask(seedData.workspaceId, "Older Review Fixture", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
await apiClient.updateTaskState(review.id, "REVIEW");
|
||||
|
||||
const inProgress = await apiClient.createTask(seedData.workspaceId, "Newer Running Fixture", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
await apiClient.updateTaskState(inProgress.id, "IN_PROGRESS");
|
||||
|
||||
// Open the anchor session so the sidebar is visible.
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const anchorCard = kanban.taskCardByTitle("Anchor Session");
|
||||
await expect(anchorCard).toBeVisible({ timeout: 15_000 });
|
||||
await anchorCard.click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
// Wait for both fixture tasks to appear in the sidebar.
|
||||
await expect(session.taskInSidebar("Older Review Fixture")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.taskInSidebar("Newer Running Fixture")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Resolve DOM order of the two fixture items in the sidebar.
|
||||
const sidebarItems = session.sidebar.getByTestId("sidebar-task-item");
|
||||
const indexOf = async (title: string): Promise<number> => {
|
||||
const count = await sidebarItems.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await sidebarItems.nth(i).innerText();
|
||||
if (text.includes(title)) return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const reviewIdx = await indexOf("Older Review Fixture");
|
||||
const runningIdx = await indexOf("Newer Running Fixture");
|
||||
|
||||
expect(reviewIdx).not.toBe(-1);
|
||||
expect(runningIdx).not.toBe(-1);
|
||||
expect(
|
||||
reviewIdx,
|
||||
`review task must sort above in_progress task (got reviewIdx=${reviewIdx}, runningIdx=${runningIdx})`,
|
||||
).toBeLessThan(runningIdx);
|
||||
|
||||
// Persistence check — reload the page and verify the order holds via
|
||||
// SSR hydration, not just client-side state.
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(session.taskInSidebar("Older Review Fixture")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.taskInSidebar("Newer Running Fixture")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const reviewIdxAfter = await indexOf("Older Review Fixture");
|
||||
const runningIdxAfter = await indexOf("Newer Running Fixture");
|
||||
expect(reviewIdxAfter).toBeLessThan(runningIdxAfter);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user